setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (115) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -1,4 +1,4 @@
1
- # pro/subwindow.py
1
+ # src/setiastro/saspro/subwindow.py
2
2
  from __future__ import annotations
3
3
  from PyQt6.QtCore import Qt, QPoint, pyqtSignal, QSize, QEvent, QByteArray, QMimeData, QSettings, QTimer, QRect, QPoint, QMargins
4
4
  from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollArea, QLabel, QToolButton, QHBoxLayout, QMessageBox, QMdiSubWindow, QMenu, QInputDialog, QApplication, QTabWidget, QRubberBand
@@ -9,6 +9,7 @@ import json
9
9
  import math
10
10
  import weakref
11
11
  import os
12
+ import re
12
13
  try:
13
14
  from PyQt6.QtCore import QSignalBlocker
14
15
  except Exception:
@@ -136,6 +137,22 @@ DECORATION_PREFIXES = (
136
137
  "Active View: ", # legacy
137
138
  )
138
139
 
140
+ _GLYPH_RE = re.compile(f"[{re.escape(GLYPHS)}]")
141
+
142
+ def _strip_ui_decorations(title: str) -> str:
143
+ if not title:
144
+ return ""
145
+ s = str(title).strip()
146
+
147
+ # Remove common prefix tag(s)
148
+ s = s.replace("[LINK]", "").strip()
149
+
150
+ # Remove glyphs anywhere (often used as status markers in titles)
151
+ s = _GLYPH_RE.sub("", s)
152
+
153
+ # Collapse repeated whitespace
154
+ s = re.sub(r"\s+", " ", s).strip()
155
+ return s
139
156
 
140
157
  from astropy.wcs import WCS as _AstroWCS
141
158
  from astropy.io.fits import Header as _FitsHeader
@@ -311,7 +328,35 @@ def _compute_cropped_wcs(parent_hdr_like, x, y, w, h):
311
328
  base["SASKIND"] = "ROI-CROP"
312
329
  return base
313
330
 
314
-
331
+ def _dnd_dbg_dump_state(tag: str, state: dict):
332
+ try:
333
+ import json
334
+ # Keep it readable but complete
335
+ keys = sorted(state.keys())
336
+ print(f"\n[DNDDBG:{tag}] keys={keys}")
337
+ for k in keys:
338
+ v = state.get(k)
339
+ # avoid huge blobs
340
+ if isinstance(v, (dict, list)) and len(str(v)) > 400:
341
+ print(f" {k} = <{type(v).__name__} len={len(v)}>")
342
+ else:
343
+ print(f" {k} = {v!r}")
344
+ # show json size
345
+ raw = json.dumps(state).encode("utf-8")
346
+ print(f"[DNDDBG:{tag}] json_bytes={len(raw)} head={raw[:120]!r}")
347
+ except Exception as e:
348
+ print(f"[DNDDBG:{tag}] dump failed: {e}")
349
+
350
+ _DEBUG_DND_DUP = False
351
+
352
+ def _strip_ext_if_filename(s: str) -> str:
353
+ s = (s or "").strip()
354
+ if not s:
355
+ return s
356
+ base, ext = os.path.splitext(s)
357
+ if ext and len(ext) <= 10:
358
+ return base
359
+ return s
315
360
 
316
361
  class ImageSubWindow(QWidget):
317
362
  aboutToClose = pyqtSignal(object)
@@ -469,6 +514,32 @@ class ImageSubWindow(QWidget):
469
514
  row.addWidget(self._btn_wcs, 0, Qt.AlignmentFlag.AlignLeft)
470
515
  # ─────────────────────────────────────────────────────────────────
471
516
 
517
+ # ---- Inline view title (shown when the MDI subwindow is maximized) ----
518
+ self._inline_title = QLabel(self)
519
+ self._inline_title.setText("")
520
+ self._inline_title.setToolTip(self.tr("Active view"))
521
+ self._inline_title.setVisible(False)
522
+ self._inline_title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
523
+ self._inline_title.setStyleSheet("""
524
+ QLabel {
525
+ padding-left: 8px;
526
+ padding-right: 6px;
527
+ font-size: 11px;
528
+ color: rgba(255,255,255,0.80);
529
+ }
530
+ """)
531
+ self._inline_title.setSizePolicy(
532
+ self._inline_title.sizePolicy().horizontalPolicy(),
533
+ self._inline_title.sizePolicy().verticalPolicy(),
534
+ )
535
+
536
+ # Push everything after this to the far right
537
+ row.addStretch(1)
538
+ row.addWidget(self._inline_title, 0, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight)
539
+
540
+ # (optional) tiny spacing to the edge
541
+ row.addSpacing(6)
542
+
472
543
  row.addStretch(1)
473
544
  lyt.addLayout(row)
474
545
 
@@ -549,6 +620,9 @@ class ImageSubWindow(QWidget):
549
620
  self._history_doc = None
550
621
  self._install_history_watchers()
551
622
 
623
+ QTimer.singleShot(0, self._install_mdi_state_watch)
624
+ QTimer.singleShot(0, self._update_inline_title_and_buttons)
625
+
552
626
  # ----- link drag payload -----
553
627
  def _start_link_drag(self):
554
628
  """
@@ -680,7 +754,60 @@ class ImageSubWindow(QWidget):
680
754
  except Exception:
681
755
  pass
682
756
 
683
- #------ Replay helpers------
757
+ # ------------------------------------------------------------
758
+ # MDI maximize handling: show inline title + avoid duplicate buttons
759
+ # ------------------------------------------------------------
760
+ def _mdi_subwindow(self) -> QMdiSubWindow | None:
761
+ w = self.parentWidget()
762
+ while w is not None and not isinstance(w, QMdiSubWindow):
763
+ w = w.parentWidget()
764
+ return w
765
+
766
+ def _install_mdi_state_watch(self):
767
+ sw = self._mdi_subwindow()
768
+ if sw is None:
769
+ return
770
+ # Watch maximize/restore changes on the hosting QMdiSubWindow
771
+ sw.installEventFilter(self)
772
+
773
+ def _is_mdi_maximized(self) -> bool:
774
+ sw = self._mdi_subwindow()
775
+ if sw is None:
776
+ return False
777
+ try:
778
+ return sw.isMaximized()
779
+ except Exception:
780
+ return False
781
+
782
+ def _set_mdi_minmax_buttons_enabled(self, enabled: bool):
783
+ return # leave Qt default buttons alone
784
+
785
+ def _current_view_title_for_inline(self) -> str:
786
+ # Prefer your already-pretty title (strip decorations if needed).
787
+ try:
788
+ # If you have _current_view_title_for_drag already, reuse it:
789
+ return self._current_view_title_for_drag()
790
+ except Exception:
791
+ pass
792
+ try:
793
+ return (self.windowTitle() or "").strip()
794
+ except Exception:
795
+ return ""
796
+
797
+ def _update_inline_title_and_buttons(self):
798
+ maximized = self._is_mdi_maximized()
799
+
800
+ # Show inline title only when maximized (optional)
801
+ try:
802
+ self._inline_title.setVisible(maximized)
803
+ if maximized:
804
+ self._inline_title.setText(self._current_view_title_for_inline() or "Untitled")
805
+ except Exception:
806
+ pass
807
+
808
+ # IMPORTANT: do NOT change QMdiSubWindow window flags.
809
+ # Leaving them alone restores the default Qt "double button" behavior.
810
+
684
811
  #------ Replay helpers------
685
812
  def _update_replay_button(self):
686
813
  """
@@ -741,15 +868,6 @@ class ImageSubWindow(QWidget):
741
868
  enabled = bool(has_preview and (has_history or has_last))
742
869
  btn.setEnabled(enabled)
743
870
 
744
- # DEBUG:
745
- try:
746
- print(
747
- f"[Replay] _update_replay_button: view id={id(self)} "
748
- f"enabled={enabled}, has_preview={has_preview}, "
749
- f"history_len={len(history)}"
750
- )
751
- except Exception:
752
- pass
753
871
 
754
872
  def _replay_history_index(self, index: int):
755
873
  """
@@ -809,11 +927,7 @@ class ImageSubWindow(QWidget):
809
927
  except Exception:
810
928
  pass
811
929
 
812
- # Emit self so the main window can locate our QMdiSubWindow wrapper.
813
- try:
814
- print(f"[Replay] Emitting replayOnBaseRequested from view id={id(self)}")
815
- except Exception:
816
- pass
930
+
817
931
  self.replayOnBaseRequested.emit(self)
818
932
 
819
933
 
@@ -823,13 +937,20 @@ class ImageSubWindow(QWidget):
823
937
  self._emit_view_transform()
824
938
 
825
939
  def set_view_transform(self, scale, hval, vval, from_link=False):
826
- # Avoid storms while we mutate scrollbars/scale
827
940
  self._suppress_link_emit = True
828
941
  try:
829
942
  scale = float(max(self._min_scale, min(scale, self._max_scale)))
830
- if abs(scale - self.scale) > 1e-9:
943
+
944
+ scale_changed = (abs(scale - self.scale) > 1e-9)
945
+ if scale_changed:
831
946
  self.scale = scale
832
- self._render(rebuild=False)
947
+ self._render(rebuild=False) # fast present for responsiveness
948
+
949
+ # ✅ NEW: schedule the final smooth redraw (same as main zoom path)
950
+ try:
951
+ self._request_zoom_redraw()
952
+ except Exception:
953
+ pass
833
954
 
834
955
  hbar = self.scroll.horizontalScrollBar()
835
956
  vbar = self.scroll.verticalScrollBar()
@@ -841,14 +962,14 @@ class ImageSubWindow(QWidget):
841
962
  finally:
842
963
  self._suppress_link_emit = False
843
964
 
844
- # IMPORTANT: if this came from a linked peer, do NOT broadcast again.
845
965
  if not from_link:
846
966
  self._schedule_emit_view_transform()
847
967
 
968
+
848
969
  def _on_toggle_wcs_grid(self, on: bool):
849
970
  self._show_wcs_grid = bool(on)
850
971
  QSettings().setValue("display/show_wcs_grid", self._show_wcs_grid)
851
- self._render(rebuild=False) # repaint current frame
972
+ self._render(rebuild=True) # repaint current frame
852
973
 
853
974
 
854
975
 
@@ -880,33 +1001,32 @@ class ImageSubWindow(QWidget):
880
1001
  # make the buttons correct right now
881
1002
  self._refresh_local_undo_buttons()
882
1003
 
883
- def _drag_identity_fields(self):
884
- """
885
- Returns a dict with identity hints for DnD:
886
- doc_uid (preferred), base_doc_uid (parent/full), and file_path.
887
- Falls back gracefully if fields are missing.
888
- """
889
- doc = getattr(self, "document", None)
890
- base = getattr(self, "base_document", None) or doc
1004
+ def _drag_identity_fields(self) -> dict:
1005
+ st = {}
891
1006
 
892
- # If DocManager maps preview/ROI views, prefer the true backing doc as base
893
- dm = getattr(self, "_docman", None)
1007
+ # existing identity (whatever you already do)
894
1008
  try:
895
- if dm and hasattr(dm, "get_document_for_view"):
896
- back = dm.get_document_for_view(self)
897
- if back is not None:
898
- base = back
1009
+ doc = getattr(self, "document", None)
1010
+ st["doc_ptr"] = id(doc) if doc is not None else None
1011
+ st["doc_uid"] = getattr(doc, "uid", None)
1012
+ meta = getattr(doc, "metadata", {}) or {}
1013
+ st["file_path"] = (meta.get("file_path") or "").strip()
1014
+ st["base_doc_uid"] = meta.get("base_doc_uid") or st["doc_uid"]
1015
+ st["source_kind"] = meta.get("source_kind") or "full"
899
1016
  except Exception:
900
1017
  pass
901
1018
 
902
- meta = (getattr(doc, "metadata", None) or {})
903
- base_meta = (getattr(base, "metadata", None) or {})
1019
+ # NEW: add the current user-visible view title
1020
+ st["source_view_title"] = self._current_view_title_for_drag()
904
1021
 
905
- return {
906
- "doc_uid": getattr(doc, "uid", None),
907
- "base_doc_uid": getattr(base, "uid", None),
908
- "file_path": meta.get("file_path") or base_meta.get("file_path") or "",
909
- }
1022
+ # (optional) also include the subwindow title raw, for debugging/forensics
1023
+ try:
1024
+ sw = self._mdi_subwindow()
1025
+ st["source_sw_title_raw"] = (sw.windowTitle() if sw is not None else "")
1026
+ except Exception:
1027
+ st["source_sw_title_raw"] = ""
1028
+
1029
+ return st
910
1030
 
911
1031
 
912
1032
  def _on_local_undo(self):
@@ -1188,18 +1308,6 @@ class ImageSubWindow(QWidget):
1188
1308
  except Exception as e:
1189
1309
  print("[ImageSubWindow] apply_layer_stack error:", e)
1190
1310
 
1191
- # --- add to ImageSubWindow ---
1192
- def _collect_layer_docs(self):
1193
- docs = set()
1194
- for L in getattr(self, "_layers", []):
1195
- d = getattr(L, "src_doc", None)
1196
- if d is not None:
1197
- docs.add(d)
1198
- md = getattr(L, "mask_doc", None)
1199
- if md is not None:
1200
- docs.add(md)
1201
- return docs
1202
-
1203
1311
  def keyPressEvent(self, ev):
1204
1312
  if ev.key() == Qt.Key.Key_Space:
1205
1313
  # only the first time we enter probe mode
@@ -1321,51 +1429,116 @@ class ImageSubWindow(QWidget):
1321
1429
  except Exception as e:
1322
1430
  print("[ImageSubWindow] _on_layer_source_changed error:", e)
1323
1431
 
1432
+ def _collect_layer_docs(self):
1433
+ """
1434
+ Collect unique ImageDocument objects referenced by the layer stack:
1435
+ - layer src_doc (if doc-backed)
1436
+ - layer mask_doc (if any)
1437
+ Raster/baked layers may have src_doc=None; those are ignored.
1438
+ Returns a LIST in a stable order (bottom→top traversal order), de-duped.
1439
+ """
1440
+ out = []
1441
+ seen = set()
1442
+
1443
+ layers = getattr(self, "_layers", None) or []
1444
+ for L in layers:
1445
+ # 1) source doc (may be None for raster/baked layers)
1446
+ d = getattr(L, "src_doc", None)
1447
+ if d is not None:
1448
+ k = id(d)
1449
+ if k not in seen:
1450
+ seen.add(k)
1451
+ out.append(d)
1452
+
1453
+ # 2) mask doc (also may be None)
1454
+ md = getattr(L, "mask_doc", None)
1455
+ if md is not None:
1456
+ k = id(md)
1457
+ if k not in seen:
1458
+ seen.add(k)
1459
+ out.append(md)
1460
+
1461
+ return out
1462
+
1463
+
1324
1464
  def _reinstall_layer_watchers(self):
1465
+ """
1466
+ Reconnect layer source/mask document watchers to trigger live layer recomposite.
1467
+ Safe against:
1468
+ - raster/baked layers (src_doc=None)
1469
+ - deleted docs / partially-torn-down Qt objects
1470
+ - repeated calls
1471
+ """
1472
+ # Previous watchers
1473
+ olddocs = list(getattr(self, "_watched_docs", []) or [])
1474
+
1325
1475
  # Disconnect old
1326
- for d in list(self._watched_docs):
1476
+ for d in olddocs:
1327
1477
  try:
1478
+ # Doc may already be deleted or signal gone
1328
1479
  d.changed.disconnect(self._on_layer_source_changed)
1329
1480
  except Exception:
1330
1481
  pass
1331
- # Connect new
1482
+
1483
+ # Collect new
1332
1484
  newdocs = self._collect_layer_docs()
1485
+
1486
+ # Connect new
1333
1487
  for d in newdocs:
1334
1488
  try:
1335
1489
  d.changed.connect(self._on_layer_source_changed)
1336
1490
  except Exception:
1337
1491
  pass
1492
+
1493
+ # Store as list (stable)
1338
1494
  self._watched_docs = newdocs
1339
1495
 
1340
1496
 
1497
+
1341
1498
  def toggle_mask_overlay(self):
1342
1499
  self.show_mask_overlay = not self.show_mask_overlay
1343
1500
  self._render(rebuild=True)
1344
1501
 
1345
1502
  def _rebuild_title(self, *, base: str | None = None):
1346
1503
  sub = self._mdi_subwindow()
1347
- if not sub: return
1504
+ if not sub:
1505
+ return
1506
+
1348
1507
  if base is None:
1349
1508
  base = self._effective_title() or self.tr("Untitled")
1350
1509
 
1351
- # strip any carried-over glyphs (🔗, ■, Active View: ”) from overrides/doc names
1510
+ # Strip badges (🔗, ■, etc) AND "Active View:" prefix
1352
1511
  core, _ = self._strip_decorations(base)
1353
1512
 
1354
- title = core
1513
+ # ALSO strip file extensions if it looks like a filename
1514
+ # (this prevents .tiff/.fit coming back via any fallback path)
1515
+ try:
1516
+ b, ext = os.path.splitext(core)
1517
+ if ext and len(ext) <= 10 and not core.endswith("..."):
1518
+ core = b
1519
+ except Exception:
1520
+ pass
1521
+
1522
+ # Build the displayed title with badges
1523
+ shown = core
1355
1524
  if getattr(self, "_link_badge_on", False):
1356
- title = f"{LINK_PREFIX}{title}"
1525
+ shown = f"{LINK_PREFIX}{shown}"
1357
1526
  if self._mask_dot_enabled:
1358
- title = f"{MASK_GLYPH} {title}"
1527
+ shown = f"{MASK_GLYPH} {shown}"
1528
+
1529
+ # Update chrome
1530
+ if shown != sub.windowTitle():
1531
+ sub.setWindowTitle(shown)
1532
+ sub.setToolTip(shown)
1533
+
1534
+ # IMPORTANT: emit ONLY the clean core (no badges, no extensions)
1535
+ if core != self._last_title_for_emit:
1536
+ self._last_title_for_emit = core
1537
+ try:
1538
+ self.viewTitleChanged.emit(self, core)
1539
+ except Exception:
1540
+ pass
1359
1541
 
1360
- if title != sub.windowTitle():
1361
- sub.setWindowTitle(title)
1362
- sub.setToolTip(title)
1363
- if title != self._last_title_for_emit:
1364
- self._last_title_for_emit = title
1365
- try: self.viewTitleChanged.emit(self, title)
1366
- except Exception as e:
1367
- import logging
1368
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1369
1542
 
1370
1543
 
1371
1544
  def _strip_decorations(self, title: str) -> tuple[str, bool]:
@@ -1394,21 +1567,7 @@ class ImageSubWindow(QWidget):
1394
1567
  def set_active_highlight(self, on: bool):
1395
1568
  self._is_active_flag = bool(on)
1396
1569
  return
1397
- sub = self._mdi_subwindow()
1398
- if not sub:
1399
- return
1400
-
1401
- core, had_glyph = self._strip_decorations(sub.windowTitle())
1402
1570
 
1403
- if on and not getattr(self, "_suppress_active_once", False):
1404
- core = ACTIVE_PREFIX + core
1405
- self._suppress_active_once = False
1406
-
1407
- # recompose: glyph (from flag), then active prefix, then base/core
1408
- if getattr(self, "_mask_dot_enabled", False):
1409
- core = "■ " + core
1410
- #sub.setWindowTitle(core)
1411
- sub.setToolTip(core)
1412
1571
 
1413
1572
  def _set_mask_highlight(self, on: bool):
1414
1573
  self._mask_dot_enabled = bool(on)
@@ -1516,15 +1675,53 @@ class ImageSubWindow(QWidget):
1516
1675
  def is_hard_autostretch(self) -> bool:
1517
1676
  return self.autostretch_profile == "hard"
1518
1677
 
1519
- def _mdi_subwindow(self) -> QMdiSubWindow | None:
1520
- w = self.parent()
1521
- while w is not None and not isinstance(w, QMdiSubWindow):
1522
- w = w.parent()
1523
- return w
1678
+ #def _mdi_subwindow(self) -> QMdiSubWindow | None:
1679
+ # w = self.parent()
1680
+ # while w is not None and not isinstance(w, QMdiSubWindow):
1681
+ # w = w.parent()
1682
+ # return w
1524
1683
 
1525
1684
  def _effective_title(self) -> str:
1526
- # Prefer a per-view override; otherwise doc display name
1527
- return self._view_title_override or self.document.display_name()
1685
+ """
1686
+ Returns the *core* title for this view (no UI badges like 🔗/■, and no file extension).
1687
+ Badges are added later by _rebuild_title().
1688
+ """
1689
+ # 1) Prefer per-view override if set
1690
+ t = (self._view_title_override or "").strip()
1691
+
1692
+ # 2) Else prefer metadata display_name (what duplicate/rename should set)
1693
+ if not t:
1694
+ try:
1695
+ md = getattr(self.document, "metadata", {}) or {}
1696
+ t = (md.get("display_name") or "").strip()
1697
+ except Exception:
1698
+ t = ""
1699
+
1700
+ # 3) Else fall back to doc.display_name()
1701
+ if not t:
1702
+ try:
1703
+ t = (self.document.display_name() or "").strip()
1704
+ except Exception:
1705
+ t = ""
1706
+
1707
+ t = t or self.tr("Untitled")
1708
+
1709
+ # Strip UI decorations (🔗, ■, Active View:, etc.)
1710
+ try:
1711
+ t, _ = self._strip_decorations(t)
1712
+ except Exception:
1713
+ pass
1714
+
1715
+ # Strip extension if it looks like a filename
1716
+ try:
1717
+ base, ext = os.path.splitext(t)
1718
+ if ext and len(ext) <= 10:
1719
+ t = base
1720
+ except Exception:
1721
+ pass
1722
+
1723
+ return t
1724
+
1528
1725
 
1529
1726
  def _show_ctx_menu(self, pos):
1530
1727
  menu = QMenu(self)
@@ -1603,13 +1800,20 @@ class ImageSubWindow(QWidget):
1603
1800
  pass
1604
1801
 
1605
1802
  def set_scale(self, s: float):
1803
+ # Programmatic scale changes must schedule final smooth redraw
1606
1804
  s = float(max(self._min_scale, min(s, self._max_scale)))
1607
1805
  if abs(s - self.scale) < 1e-9:
1608
1806
  return
1609
1807
  self.scale = s
1610
- self._render() # only scale needs a redraw
1808
+ self._render() # fast present happens here
1611
1809
  self._schedule_emit_view_transform()
1612
1810
 
1811
+ # ✅ NEW: ensure we do the final smooth redraw (same as manual zoom)
1812
+ try:
1813
+ self._request_zoom_redraw()
1814
+ except Exception:
1815
+ pass
1816
+
1613
1817
 
1614
1818
 
1615
1819
  # ---- view state API (center in image coords + scale) ----
@@ -1634,20 +1838,19 @@ class ImageSubWindow(QWidget):
1634
1838
  vbar = self.scroll.verticalScrollBar()
1635
1839
 
1636
1840
  state = {
1637
- "doc_ptr": id(self.document), # legacy
1841
+ "doc_ptr": id(self.document),
1638
1842
  "scale": float(self.scale),
1639
1843
  "hval": int(hbar.value()),
1640
1844
  "vval": int(vbar.value()),
1641
1845
  "autostretch": bool(self.autostretch_enabled),
1642
1846
  "autostretch_target": float(self.autostretch_target),
1643
1847
  }
1644
- state.update(self._drag_identity_fields()) # uid + base_uid + file_path
1848
+ state.update(self._drag_identity_fields())
1645
1849
 
1646
- # --- NEW: annotate ROI/source_kind so drop knows this came from a Preview tab
1647
1850
  roi = None
1648
1851
  try:
1649
1852
  if hasattr(self, "has_active_preview") and self.has_active_preview():
1650
- r = self.current_preview_roi() # (x,y,w,h) in full-image coords
1853
+ r = self.current_preview_roi()
1651
1854
  if r and len(r) == 4:
1652
1855
  roi = tuple(map(int, r))
1653
1856
  except Exception:
@@ -1665,14 +1868,29 @@ class ImageSubWindow(QWidget):
1665
1868
  else:
1666
1869
  state["source_kind"] = "full"
1667
1870
 
1871
+ if _DEBUG_DND_DUP:
1872
+ _dnd_dbg_dump_state("DRAG_START:dragtab", state)
1873
+
1874
+
1668
1875
  md = QMimeData()
1669
1876
  md.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
1670
1877
 
1671
1878
  drag = QDrag(self)
1672
1879
  drag.setMimeData(md)
1673
- if self.label.pixmap():
1674
- drag.setPixmap(self.label.pixmap())
1675
- drag.exec()
1880
+
1881
+ pm = self.label.pixmap()
1882
+ if pm and not pm.isNull():
1883
+ drag.setPixmap(
1884
+ pm.scaled(
1885
+ 96, 96,
1886
+ Qt.AspectRatioMode.KeepAspectRatio,
1887
+ Qt.TransformationMode.SmoothTransformation,
1888
+ )
1889
+ )
1890
+ drag.setHotSpot(QPoint(16, 16)) # optional, but feels nicer
1891
+
1892
+ drag.exec(Qt.DropAction.CopyAction)
1893
+
1676
1894
 
1677
1895
 
1678
1896
 
@@ -1775,6 +1993,55 @@ class ImageSubWindow(QWidget):
1775
1993
 
1776
1994
 
1777
1995
  # ---- DnD 'view tab' -------------------------------------------------
1996
+
1997
+ def _mdi_subwindow(self):
1998
+ """Return the QMdiSubWindow that hosts this view, or None."""
1999
+ try:
2000
+ from PyQt6.QtWidgets import QMdiSubWindow
2001
+ p = self.parent()
2002
+ while p is not None:
2003
+ if isinstance(p, QMdiSubWindow):
2004
+ return p
2005
+ p = p.parent()
2006
+ except Exception:
2007
+ pass
2008
+ return None
2009
+
2010
+ def _current_view_title_for_drag(self) -> str:
2011
+ """
2012
+ The *actual* user-visible view title (what they renamed to),
2013
+ NOT the document/file name.
2014
+ """
2015
+ title = ""
2016
+ try:
2017
+ sw = self._mdi_subwindow()
2018
+ if sw is not None:
2019
+ title = (sw.windowTitle() or "").strip()
2020
+ except Exception:
2021
+ title = ""
2022
+
2023
+ if not title:
2024
+ try:
2025
+ title = (self.windowTitle() or "").strip()
2026
+ except Exception:
2027
+ title = ""
2028
+
2029
+ if not title:
2030
+ # absolute fallback
2031
+ try:
2032
+ title = (self.document.display_name() or "").strip()
2033
+ except Exception:
2034
+ title = ""
2035
+
2036
+ # Optional: strip [LINK], glyphs, etc if your title includes those
2037
+ try:
2038
+ title = _strip_ui_decorations(title)
2039
+ except Exception:
2040
+ pass
2041
+
2042
+ return title or "Untitled"
2043
+
2044
+
1778
2045
  def _install_view_tab(self):
1779
2046
  self._view_tab = QToolButton(self)
1780
2047
  self._view_tab.setText(self.tr("View"))
@@ -1794,19 +2061,28 @@ class ImageSubWindow(QWidget):
1794
2061
  if ev.button() != Qt.MouseButton.LeftButton:
1795
2062
  return QToolButton.mousePressEvent(self._view_tab, ev)
1796
2063
 
1797
- # build the SAME payload schema used by _start_viewstate_drag()
1798
2064
  hbar = self.scroll.horizontalScrollBar()
1799
2065
  vbar = self.scroll.verticalScrollBar()
2066
+
2067
+ # NEW: capture the *current view title* the user sees
2068
+ view_title = self._current_view_display_name()
2069
+
1800
2070
  state = {
1801
2071
  "doc_ptr": id(self.document),
2072
+ "doc_uid": getattr(self.document, "uid", None), # harmless even if None
2073
+ "file_path": (getattr(self.document, "metadata", {}) or {}).get("file_path", ""),
1802
2074
  "scale": float(self.scale),
1803
2075
  "hval": int(hbar.value()),
1804
2076
  "vval": int(vbar.value()),
1805
2077
  "autostretch": bool(self.autostretch_enabled),
1806
2078
  "autostretch_target": float(self.autostretch_target),
2079
+
2080
+ # NEW: this is what we will use for naming duplicates
2081
+ "source_view_title": view_title,
1807
2082
  }
1808
2083
  state.update(self._drag_identity_fields())
1809
-
2084
+ if _DEBUG_DND_DUP:
2085
+ _dnd_dbg_dump_state("DRAG_START:viewtab", state)
1810
2086
  mime = QMimeData()
1811
2087
  mime.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
1812
2088
 
@@ -1815,10 +2091,13 @@ class ImageSubWindow(QWidget):
1815
2091
 
1816
2092
  pm = self.label.pixmap()
1817
2093
  if pm:
1818
- drag.setPixmap(pm.scaled(96, 96,
1819
- Qt.AspectRatioMode.KeepAspectRatio,
1820
- Qt.TransformationMode.SmoothTransformation))
2094
+ drag.setPixmap(pm.scaled(
2095
+ 96, 96,
2096
+ Qt.AspectRatioMode.KeepAspectRatio,
2097
+ Qt.TransformationMode.SmoothTransformation
2098
+ ))
1821
2099
  drag.setHotSpot(QCursor.pos() - self.mapToGlobal(self._view_tab.pos()))
2100
+
1822
2101
  drag.exec(Qt.DropAction.CopyAction)
1823
2102
 
1824
2103
  def _viewtab_mouse_double(self, _ev):
@@ -1924,6 +2203,42 @@ class ImageSubWindow(QWidget):
1924
2203
 
1925
2204
  ev.ignore()
1926
2205
 
2206
+ def _current_view_display_name(self) -> str:
2207
+ """
2208
+ Best-effort: the exact title the user sees for THIS subwindow/view.
2209
+ Prefer QMdiSubWindow title, fallback to document display_name.
2210
+ """
2211
+ # 1) QMdiSubWindow title (what user sees)
2212
+ try:
2213
+ sw = self._mdi_subwindow()
2214
+ if sw is not None:
2215
+ t = (sw.windowTitle() or "").strip()
2216
+ if t:
2217
+ return t
2218
+ except Exception:
2219
+ pass
2220
+
2221
+ # 2) This widget's own windowTitle (sometimes used)
2222
+ try:
2223
+ t = (self.windowTitle() or "").strip()
2224
+ if t:
2225
+ return t
2226
+ except Exception:
2227
+ pass
2228
+
2229
+ # 3) Document display name fallback
2230
+ try:
2231
+ d = getattr(self, "document", None)
2232
+ if d is not None and hasattr(d, "display_name"):
2233
+ t = (d.display_name() or "").strip()
2234
+ if t:
2235
+ return t
2236
+ except Exception:
2237
+ pass
2238
+
2239
+ return "Untitled"
2240
+
2241
+
1927
2242
  # keep the tab visible if the widget resizes
1928
2243
  def resizeEvent(self, ev):
1929
2244
  super().resizeEvent(ev)
@@ -2075,9 +2390,16 @@ class ImageSubWindow(QWidget):
2075
2390
 
2076
2391
  # ---------- rendering ----------
2077
2392
  def _render(self, rebuild: bool = False):
2393
+ #print("[ImageSubWindow] _render called, rebuild =", rebuild)
2078
2394
  """
2079
2395
  Render the current view.
2080
2396
 
2397
+ Fast path:
2398
+ - rebuild=False: only rescale already-built pixmap/QImage (NO numpy work).
2399
+ Slow path:
2400
+ - rebuild=True: rebuild visualization (autostretch, 8-bit conversion, overlays),
2401
+ refresh QImage/QPixmap cache, then present.
2402
+
2081
2403
  Rules:
2082
2404
  - If a Preview is active, FIRST sync that preview's stored arr from the
2083
2405
  DocManager's ROI document (the thing tools actually modify), then render.
@@ -2087,46 +2409,53 @@ class ImageSubWindow(QWidget):
2087
2409
  # ---- GUARD: widget/label may be deleted but document.changed still fires ----
2088
2410
  try:
2089
2411
  from PyQt6 import sip as _sip
2090
- # If the whole widget or its label is gone, bail immediately
2091
2412
  if _sip.isdeleted(self):
2092
2413
  return
2093
2414
  lbl = getattr(self, "label", None)
2094
2415
  if lbl is None or _sip.isdeleted(lbl):
2095
2416
  return
2096
2417
  except Exception:
2097
- # If sip or label is missing for any reason, play it safe
2098
2418
  if not hasattr(self, "label"):
2099
2419
  return
2100
- # ---------------------------------------------------------------------------
2420
+ # ---------------------------------------------------------------------------
2421
+
2422
+ # ---------------------------------------------------------------------------
2423
+ # FAST PATH: if we're not rebuilding content and we already have a source pixmap,
2424
+ # just present scaled (fast). This is the key to smooth zoom.
2425
+ # ---------------------------------------------------------------------------
2426
+ if (not rebuild) and getattr(self, "_pm_src", None) is not None:
2427
+ self._present_scaled(interactive=True)
2428
+ return
2429
+
2101
2430
  # ---------------------------
2102
2431
  # 1) Choose & sync source arr
2103
2432
  # ---------------------------
2104
2433
  base_img = None
2105
2434
  if self._active_source_kind == "preview" and self._active_preview_id is not None:
2106
2435
  src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2107
- #print("[ImageSubWindow] _render: preview mode, id =", self._active_preview_id, "src =", src is not None)
2108
2436
  if src is not None:
2109
2437
  # Pull the *edited* ROI image from DocManager, if available
2110
2438
  if hasattr(self, "_docman") and self._docman is not None:
2111
- #print("[ImageSubWindow] _render: pulling edited ROI from DocManager")
2112
2439
  try:
2113
2440
  roi_doc = self._docman.get_document_for_view(self)
2114
2441
  roi_img = getattr(roi_doc, "image", None)
2442
+ # IMPORTANT: only copy on rebuild; zoom should not trigger a copy
2115
2443
  if roi_img is not None:
2116
- # Replace the preview’s static copy with the edited ROI buffer
2117
- src["arr"] = np.asarray(roi_img).copy()
2444
+ if rebuild or ("arr" not in src) or (src.get("arr") is None):
2445
+ src["arr"] = np.asarray(roi_img).copy()
2118
2446
  except Exception:
2119
2447
  print("[ImageSubWindow] _render: failed to pull edited ROI from DocManager")
2120
- pass
2121
2448
  base_img = src.get("arr", None)
2122
2449
  else:
2123
- #print("[ImageSubWindow] _render: full image mode")
2124
2450
  base_img = self._display_override if (self._display_override is not None) else (
2125
2451
  getattr(self.document, "image", None)
2126
2452
  )
2127
2453
 
2128
2454
  if base_img is None:
2129
2455
  self._qimg_src = None
2456
+ self._pm_src = None
2457
+ self._pm_src_wcs = None
2458
+ self._buf8 = None
2130
2459
  self.label.clear()
2131
2460
  return
2132
2461
 
@@ -2135,7 +2464,6 @@ class ImageSubWindow(QWidget):
2135
2464
  # ---------------------------------------
2136
2465
  # 2) Normalize dimensionality and dtype
2137
2466
  # ---------------------------------------
2138
- # Scalar → 1x1; 1D → 1xN; (H,W,1) → mono (H,W)
2139
2467
  if arr.ndim == 0:
2140
2468
  arr = arr.reshape(1, 1)
2141
2469
  elif arr.ndim == 1:
@@ -2156,7 +2484,7 @@ class ImageSubWindow(QWidget):
2156
2484
  else:
2157
2485
  arr_f = arr.astype(np.float32, copy=False)
2158
2486
  mx = float(arr_f.max()) if arr_f.size else 1.0
2159
- if mx > 5.0: # compress absurdly large ranges
2487
+ if mx > 5.0:
2160
2488
  arr_f = arr_f / mx
2161
2489
 
2162
2490
  vis = autostretch(
@@ -2192,7 +2520,7 @@ class ImageSubWindow(QWidget):
2192
2520
  buf8 = np.stack([buf8.squeeze()] * 3, axis=-1)
2193
2521
 
2194
2522
  # ---------------------------------------
2195
- # 5) Optional mask overlay
2523
+ # 5) Optional mask overlay (baked into buf8)
2196
2524
  # ---------------------------------------
2197
2525
  if getattr(self, "show_mask_overlay", False):
2198
2526
  m = self._active_mask_array()
@@ -2215,9 +2543,9 @@ class ImageSubWindow(QWidget):
2215
2543
  # ---------------------------------------
2216
2544
  if buf8.dtype != np.uint8:
2217
2545
  buf8 = buf8.astype(np.uint8)
2546
+
2218
2547
  buf8 = ensure_contiguous(buf8)
2219
2548
  h, w, c = buf8.shape
2220
- # Be explicit. RGB888 means 3 bytes per pixel, full stop.
2221
2549
  bytes_per_line = int(w * 3)
2222
2550
 
2223
2551
  self._buf8 = buf8 # keep alive
@@ -2226,11 +2554,9 @@ class ImageSubWindow(QWidget):
2226
2554
  addr = int(self._buf8.ctypes.data)
2227
2555
  ptr = sip.voidptr(addr)
2228
2556
  qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
2229
- # Defensive: if Qt ever decides the buffer looks wrong, force-copy once
2230
2557
  if qimg is None or qimg.isNull():
2231
2558
  raise RuntimeError("QImage null")
2232
2559
  except Exception:
2233
- # One safe fall-back copy (still fast, avoids crashes)
2234
2560
  buf8c = np.array(self._buf8, copy=True, order="C")
2235
2561
  self._buf8 = buf8c
2236
2562
  addr = int(self._buf8.ctypes.data)
@@ -2239,244 +2565,263 @@ class ImageSubWindow(QWidget):
2239
2565
 
2240
2566
  self._qimg_src = qimg
2241
2567
  if qimg is None or qimg.isNull():
2568
+ self._pm_src = None
2569
+ self._pm_src_wcs = None
2242
2570
  self.label.clear()
2243
2571
  return
2244
2572
 
2245
- # ---------------------------------------
2246
- # 7) Scale & present
2247
- # ---------------------------------------
2248
- sw = max(1, int(qimg.width() * self.scale))
2249
- sh = max(1, int(qimg.height() * self.scale))
2250
- scaled = qimg.scaled(
2251
- sw, sh,
2252
- Qt.AspectRatioMode.KeepAspectRatio,
2253
- Qt.TransformationMode.SmoothTransformation
2254
- )
2573
+ # Cache unscaled pixmap ONCE per rebuild
2574
+ self._pm_src = QPixmap.fromImage(self._qimg_src)
2255
2575
 
2256
- # ── NEW: WCS grid overlay (draw on the scaled pixmap so lines stay 1px) ──
2257
- if getattr(self, "_show_wcs_grid", False):
2258
- wcs2 = self._get_celestial_wcs()
2259
- if wcs2 is not None:
2260
- from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
2261
- from PyQt6.QtCore import QSettings
2262
- from astropy.wcs.utils import proj_plane_pixel_scales
2263
- import numpy as _np
2264
-
2265
- pm = QPixmap.fromImage(scaled)
2266
-
2267
- # Read user prefs (fallback to defaults if not set)
2268
- _settings = getattr(self, "_settings", None) or QSettings()
2269
- pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
2270
- pref_mode = _settings.value("wcs_grid/mode", "auto", type=str) # "auto" | "fixed"
2271
- pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str) # "deg" | "arcmin"
2272
- pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
2273
-
2274
- if not pref_enabled:
2275
- # User disabled the grid in Preferences — skip overlay
2276
- self.label.setPixmap(QPixmap.fromImage(scaled))
2277
- self.label.resize(scaled.size())
2278
- return
2576
+ # Invalidate any cached “WCS baked” pixmap on rebuild
2577
+ self._pm_src_wcs = None
2279
2578
 
2280
- display_h, display_w = base_img.shape[:2]
2579
+ # Present final-quality after rebuild
2580
+ self._present_scaled(interactive=False)
2281
2581
 
2282
- # Pixel scales and FOV using celestial WCS
2283
- px_scales_deg = proj_plane_pixel_scales(wcs2) # deg/pix for the two celestial axes
2284
- px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
2582
+ rebuild = False # done
2285
2583
 
2286
- H_full, W_full = display_h, display_w
2287
- fov_deg = px_deg * float(max(W_full, H_full))
2288
2584
 
2289
- # Choose grid spacing from prefs (or auto heuristic)
2290
- if pref_mode == "fixed":
2291
- step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
2292
- step_deg = max(1e-6, min(step_deg, 90.0)) # clamp to sane range
2293
- else:
2294
- # Auto spacing (your previous logic)
2295
- nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
2296
- target_lines = 8
2297
- desired = max(fov_deg / target_lines, px_deg * 100)
2298
- step_deg = min((n for n in nice if n >= desired), default=30)
2299
-
2300
- # World rect from image corners using celestial WCS
2301
- corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
2302
- try:
2303
- ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
2304
- ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
2305
- dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
2306
- if ra_max - ra_min > 300:
2307
- ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
2308
- ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
2309
- ra_shift = 180.0
2310
- else:
2311
- ra_shift = 0.0
2312
- except Exception:
2313
- ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
2314
-
2315
- p = QPainter(pm)
2316
- pen = QPen(); pen.setWidth(1); pen.setColor(QColor(255, 255, 255, 140))
2317
- p.setPen(pen)
2318
- s = float(self.scale)
2319
- img_w = int(W_full * s)
2320
- img_h = int(H_full * s)
2321
- Wf, Hf = float(W_full), float(H_full)
2322
- margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
2323
- def draw_world_poly(xs_world, ys_world):
2324
- try:
2325
- px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
2326
- except Exception:
2327
- return
2328
-
2329
- px = _np.asarray(px, dtype=float)
2330
- py = _np.asarray(py, dtype=float)
2331
-
2332
- # --- validity mask ---
2333
- ok = _np.isfinite(px) & _np.isfinite(py)
2334
-
2335
- # Allow a margin around the image so near-edge lines still draw
2336
- margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
2337
- ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
2338
- ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
2339
-
2340
- for i in range(1, len(px)):
2341
- if not (ok[i-1] and ok[i]):
2342
- continue
2343
-
2344
- x0 = float(px[i-1]) * s
2345
- y0 = float(py[i-1]) * s
2346
- x1 = float(px[i]) * s
2347
- y1 = float(py[i]) * s
2348
-
2349
- # Final sanity gate before int() -> Qt 32-bit
2350
- if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
2351
- continue
2352
-
2353
- p.drawLine(int(x0), int(y0), int(x1), int(y1))
2354
-
2355
-
2356
- ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
2357
- ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
2358
- dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
2359
-
2360
- # DEC lines (horiz-ish)
2361
- def _frange(a,b,s):
2362
- out=[]; x=a
2363
- while x <= b + 1e-9:
2364
- out.append(x); x += s
2365
- return out
2366
- def _round_to(x,s): return s * round(x/s)
2367
-
2368
- ra_start = _round_to(ra_min, step_deg)
2369
- dec_start = _round_to(dec_min, step_deg)
2370
- for dec in _frange(dec_start, dec_max, step_deg):
2371
- dec_arr = _np.full_like(ra_samples_wrapped, dec)
2372
- draw_world_poly(ra_samples_wrapped, dec_arr)
2373
-
2374
- # RA lines (vert-ish)
2375
- for ra in _frange(ra_start, ra_max, step_deg):
2376
- ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
2377
- draw_world_poly(ra_arr, dec_samples)
2378
-
2379
- # ── LABELS for RA/Dec lines ─────────────────────────────────
2380
- # Font & box style
2381
- font = QFont(); font.setPixelSize(11) # screen-consistent
2382
- p.setFont(font)
2383
- text_pen = QPen(QColor(255, 255, 255, 230))
2384
- box_brush = QBrush(QColor(0, 0, 0, 140))
2385
- p.setPen(text_pen)
2386
-
2387
- def _draw_label(x, y, txt, anchor="lt"):
2388
- if not _np.isfinite([x, y]).all():
2389
- return
2390
- fm = p.fontMetrics()
2391
- wtxt = fm.horizontalAdvance(txt) + 6
2392
- htxt = fm.height() + 4
2393
-
2394
- # initial placement with a little padding
2395
- if anchor == "lt": # left-top
2396
- rx, ry = int(x) + 4, int(y) + 3
2397
- elif anchor == "rt": # right-top
2398
- rx, ry = int(x) - wtxt - 4, int(y) + 3
2399
- elif anchor == "lb": # left-bottom
2400
- rx, ry = int(x) + 4, int(y) - htxt - 3
2401
- else: # center-top
2402
- rx, ry = int(x) - wtxt // 2, int(y) + 3
2403
-
2404
- # clamp entirely inside the image
2405
- rx = max(0, min(rx, img_w - wtxt - 1))
2406
- ry = max(0, min(ry, img_h - htxt - 1))
2407
-
2408
- rect = QRect(rx, ry, wtxt, htxt)
2409
- p.save()
2410
- p.setBrush(box_brush)
2411
- p.setPen(Qt.PenStyle.NoPen)
2412
- p.drawRoundedRect(rect, 4, 4)
2413
- p.restore()
2414
- p.drawText(rect.adjusted(3, 2, -3, -2),
2415
- Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
2416
-
2417
-
2418
- # DEC labels on left edge
2419
- for dec in _frange(dec_start, dec_max, step_deg):
2420
- try:
2421
- x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
2422
- if not _np.isfinite([x_pix, y_pix]).all():
2423
- continue
2424
- # clamp to image bounds before scaling
2425
- x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2426
- y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2427
- _draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
2428
- except Exception:
2429
- pass
2585
+ def _present_scaled(self, interactive: bool):
2586
+ """
2587
+ Present the cached source pixmap scaled to current self.scale.
2588
+
2589
+ interactive=True:
2590
+ - Fast scaling
2591
+ - No WCS draw
2592
+ interactive=False:
2593
+ - Smooth scaling
2594
+ - Optionally draw WCS overlay once
2595
+ """
2596
+ if getattr(self, "_pm_src", None) is None:
2597
+ return
2430
2598
 
2431
- # RA labels on top edge
2432
- for ra in _frange(ra_start, ra_max, step_deg):
2433
- ra_wrapped = (ra + ra_shift) % 360.0
2434
- try:
2435
- x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
2436
- if not _np.isfinite([x_pix, y_pix]).all():
2437
- continue
2438
- x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2439
- y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2440
- _draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
2441
- except Exception:
2442
- pass
2599
+ pm_base = self._pm_src
2443
2600
 
2444
- p.end()
2445
- scaled = pm.toImage()
2601
+ sw = max(1, int(pm_base.width() * self.scale))
2602
+ sh = max(1, int(pm_base.height() * self.scale))
2446
2603
 
2447
- # ── end WCS grid overlay ────────────────────────────────────────────────
2604
+ mode = Qt.TransformationMode.FastTransformation if interactive else Qt.TransformationMode.SmoothTransformation
2605
+ pm_scaled = pm_base.scaled(sw, sh, Qt.AspectRatioMode.KeepAspectRatio, mode)
2448
2606
 
2449
- self.label.setPixmap(QPixmap.fromImage(scaled))
2450
- self.label.resize(scaled.size())
2607
+ # If interactive, skip WCS overlay entirely (this is the biggest speed win)
2608
+ if interactive:
2609
+ self.label.setPixmap(pm_scaled)
2610
+ self.label.resize(pm_scaled.size())
2611
+ return
2451
2612
 
2613
+ # Non-interactive: (optionally) draw WCS grid.
2614
+ if getattr(self, "_show_wcs_grid", False):
2615
+ # Cache a baked WCS pixmap at *this* scale to avoid re-drawing
2616
+ # if _present_scaled(False) is called multiple times at same scale.
2617
+ cache_key = (sw, sh, float(self.scale))
2618
+ if getattr(self, "_pm_src_wcs_key", None) != cache_key or getattr(self, "_pm_src_wcs", None) is None:
2619
+ pm_scaled = self._draw_wcs_grid_on_pixmap(pm_scaled)
2620
+ self._pm_src_wcs = pm_scaled
2621
+ self._pm_src_wcs_key = cache_key
2622
+ else:
2623
+ pm_scaled = self._pm_src_wcs
2452
2624
 
2625
+ self.label.setPixmap(pm_scaled)
2626
+ self.label.resize(pm_scaled.size())
2453
2627
 
2454
- def has_active_preview(self) -> bool:
2455
- return self._active_source_kind == "preview" and self._active_preview_id is not None
2456
2628
 
2457
- def current_preview_roi(self) -> tuple[int,int,int,int] | None:
2629
+ def _draw_wcs_grid_on_pixmap(self, pm_scaled: QPixmap) -> QPixmap:
2458
2630
  """
2459
- Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
2631
+ Your existing WCS painter logic, moved to operate on a QPixmap (already scaled).
2632
+ Runs ONLY on non-interactive redraw.
2460
2633
  """
2461
- if not self.has_active_preview():
2462
- return None
2463
- src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2464
- return None if src is None else tuple(src["roi"])
2634
+ wcs2 = self._get_celestial_wcs()
2635
+ if wcs2 is None:
2636
+ return pm_scaled
2637
+
2638
+ from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
2639
+ from PyQt6.QtCore import QSettings, QRect
2640
+ from astropy.wcs.utils import proj_plane_pixel_scales
2641
+ import numpy as _np
2642
+
2643
+ _settings = getattr(self, "_settings", None) or QSettings()
2644
+ pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
2645
+ pref_mode = _settings.value("wcs_grid/mode", "auto", type=str)
2646
+ pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str)
2647
+ pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
2648
+
2649
+ if not pref_enabled:
2650
+ return pm_scaled
2651
+
2652
+ # Determine full image geometry from the CURRENT SOURCE buffer (not pm_scaled)
2653
+ # We can infer W/H from qimg src (original)
2654
+ if getattr(self, "_qimg_src", None) is None:
2655
+ return pm_scaled
2656
+ H_full = int(self._qimg_src.height())
2657
+ W_full = int(self._qimg_src.width())
2658
+
2659
+ # Pixel scales/FOV
2660
+ px_scales_deg = proj_plane_pixel_scales(wcs2)
2661
+ px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
2662
+ fov_deg = px_deg * float(max(W_full, H_full))
2663
+
2664
+ if pref_mode == "fixed":
2665
+ step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
2666
+ step_deg = max(1e-6, min(step_deg, 90.0))
2667
+ else:
2668
+ nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
2669
+ target_lines = 8
2670
+ desired = max(fov_deg / target_lines, px_deg * 100)
2671
+ step_deg = min((n for n in nice if n >= desired), default=30)
2465
2672
 
2466
- def current_preview_name(self) -> str | None:
2467
- if not self.has_active_preview():
2468
- return None
2469
- src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2470
- return None if src is None else src["name"]
2673
+ # World bounds from corners
2674
+ corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
2675
+ try:
2676
+ ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
2677
+ ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
2678
+ dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
2679
+ if ra_max - ra_min > 300:
2680
+ ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
2681
+ ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
2682
+ ra_shift = 180.0
2683
+ else:
2684
+ ra_shift = 0.0
2685
+ except Exception:
2686
+ ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
2687
+
2688
+ pm = QPixmap(pm_scaled) # copy so we don’t mutate caller
2689
+ p = QPainter(pm)
2690
+ pen = QPen(QColor(255, 255, 255, 140))
2691
+ pen.setWidth(1)
2692
+ p.setPen(pen)
2693
+
2694
+ # Scale factor between full-res image and pm_scaled
2695
+ s = float(pm.width()) / float(max(1, W_full))
2696
+
2697
+ Wf, Hf = float(W_full), float(H_full)
2698
+
2699
+ def draw_world_poly(xs_world, ys_world):
2700
+ try:
2701
+ px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
2702
+ except Exception:
2703
+ return
2704
+
2705
+ px = _np.asarray(px, dtype=float)
2706
+ py = _np.asarray(py, dtype=float)
2707
+
2708
+ ok = _np.isfinite(px) & _np.isfinite(py)
2709
+ margin = float(max(Wf, Hf) * 2.0)
2710
+ ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
2711
+ ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
2712
+
2713
+ for i in range(1, len(px)):
2714
+ if not (ok[i-1] and ok[i]):
2715
+ continue
2716
+ x0 = float(px[i-1]) * s
2717
+ y0 = float(py[i-1]) * s
2718
+ x1 = float(px[i]) * s
2719
+ y1 = float(py[i]) * s
2720
+ if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
2721
+ continue
2722
+ p.drawLine(int(x0), int(y0), int(x1), int(y1))
2723
+
2724
+ ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
2725
+ ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
2726
+ dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
2727
+
2728
+ def _frange(a, b, sstep):
2729
+ out = []
2730
+ x = a
2731
+ while x <= b + 1e-9:
2732
+ out.append(x)
2733
+ x += sstep
2734
+ return out
2735
+
2736
+ def _round_to(x, sstep):
2737
+ return sstep * round(x / sstep)
2738
+
2739
+ ra_start = _round_to(ra_min, step_deg)
2740
+ dec_start = _round_to(dec_min, step_deg)
2741
+
2742
+ for dec in _frange(dec_start, dec_max, step_deg):
2743
+ dec_arr = _np.full_like(ra_samples_wrapped, dec)
2744
+ draw_world_poly(ra_samples_wrapped, dec_arr)
2745
+
2746
+ for ra in _frange(ra_start, ra_max, step_deg):
2747
+ ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
2748
+ draw_world_poly(ra_arr, dec_samples)
2749
+
2750
+ # Labels
2751
+ font = QFont()
2752
+ font.setPixelSize(11)
2753
+ p.setFont(font)
2754
+ text_pen = QPen(QColor(255, 255, 255, 230))
2755
+ box_brush = QBrush(QColor(0, 0, 0, 140))
2756
+ p.setPen(text_pen)
2757
+
2758
+ img_w = pm.width()
2759
+ img_h = pm.height()
2760
+
2761
+ def _draw_label(x, y, txt, anchor="lt"):
2762
+ if not _np.isfinite([x, y]).all():
2763
+ return
2764
+ fm = p.fontMetrics()
2765
+ wtxt = fm.horizontalAdvance(txt) + 6
2766
+ htxt = fm.height() + 4
2767
+
2768
+ if anchor == "lt":
2769
+ rx, ry = int(x) + 4, int(y) + 3
2770
+ elif anchor == "rt":
2771
+ rx, ry = int(x) - wtxt - 4, int(y) + 3
2772
+ elif anchor == "lb":
2773
+ rx, ry = int(x) + 4, int(y) - htxt - 3
2774
+ else:
2775
+ rx, ry = int(x) - wtxt // 2, int(y) + 3
2776
+
2777
+ rx = max(0, min(rx, img_w - wtxt - 1))
2778
+ ry = max(0, min(ry, img_h - htxt - 1))
2779
+
2780
+ rect = QRect(rx, ry, wtxt, htxt)
2781
+ p.save()
2782
+ p.setBrush(box_brush)
2783
+ p.setPen(Qt.PenStyle.NoPen)
2784
+ p.drawRoundedRect(rect, 4, 4)
2785
+ p.restore()
2786
+ p.drawText(rect.adjusted(3, 2, -3, -2),
2787
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
2788
+
2789
+ # DEC labels on left edge
2790
+ for dec in _frange(dec_start, dec_max, step_deg):
2791
+ try:
2792
+ x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
2793
+ if not _np.isfinite([x_pix, y_pix]).all():
2794
+ continue
2795
+ x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2796
+ y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2797
+ _draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
2798
+ except Exception:
2799
+ pass
2800
+
2801
+ # RA labels on top edge
2802
+ for ra in _frange(ra_start, ra_max, step_deg):
2803
+ ra_wrapped = (ra + ra_shift) % 360.0
2804
+ try:
2805
+ x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
2806
+ if not _np.isfinite([x_pix, y_pix]).all():
2807
+ continue
2808
+ x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2809
+ y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2810
+ _draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
2811
+ except Exception:
2812
+ pass
2813
+
2814
+ p.end()
2815
+ return pm
2471
2816
 
2472
2817
 
2473
2818
  # ---------- interaction ----------
2474
2819
  def _zoom_at_anchor(self, factor: float):
2475
- if self._qimg_src is None:
2820
+ if getattr(self, "_qimg_src", None) is None and getattr(self, "_pm_src", None) is None:
2476
2821
  return
2477
- old_scale = self.scale
2478
- # clamp with new max
2479
- new_scale = max(self._min_scale, min(old_scale * factor, self._max_scale))
2822
+
2823
+ old_scale = float(self.scale)
2824
+ new_scale = max(self._min_scale, min(old_scale * float(factor), self._max_scale))
2480
2825
  if abs(new_scale - old_scale) < 1e-8:
2481
2826
  return
2482
2827
 
@@ -2484,7 +2829,6 @@ class ImageSubWindow(QWidget):
2484
2829
  hbar = self.scroll.horizontalScrollBar()
2485
2830
  vbar = self.scroll.verticalScrollBar()
2486
2831
 
2487
- # Anchor in viewport coordinates via global cursor (robust)
2488
2832
  try:
2489
2833
  anchor_vp = vp.mapFromGlobal(QCursor.pos())
2490
2834
  except Exception:
@@ -2493,34 +2837,77 @@ class ImageSubWindow(QWidget):
2493
2837
  if (anchor_vp is None) or (not vp.rect().contains(anchor_vp)):
2494
2838
  anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
2495
2839
 
2496
- # Current label coords under the anchor
2497
2840
  x_label_pre = hbar.value() + anchor_vp.x()
2498
2841
  y_label_pre = vbar.value() + anchor_vp.y()
2499
2842
 
2500
- # Convert to image coords at old scale
2501
2843
  xi = x_label_pre / max(old_scale, 1e-12)
2502
2844
  yi = y_label_pre / max(old_scale, 1e-12)
2503
2845
 
2504
- # Apply scale and redraw (updates label size + scrollbar ranges)
2846
+ # Apply new scale
2505
2847
  self.scale = new_scale
2506
- self._render(rebuild=False)
2507
2848
 
2508
- # Reproject that image point to label coords at new scale
2849
+ # FAST present (no rebuild)
2850
+ self._present_scaled(interactive=True)
2851
+
2852
+ # Keep anchor stable
2509
2853
  x_label_post = xi * new_scale
2510
2854
  y_label_post = yi * new_scale
2511
2855
 
2512
- # Desired scrollbar values to keep point under the cursor
2513
2856
  new_h = int(round(x_label_post - anchor_vp.x()))
2514
2857
  new_v = int(round(y_label_post - anchor_vp.y()))
2515
2858
 
2516
- # Clamp to valid range
2517
2859
  new_h = max(hbar.minimum(), min(new_h, hbar.maximum()))
2518
2860
  new_v = max(vbar.minimum(), min(new_v, vbar.maximum()))
2519
2861
 
2520
- # Apply
2521
2862
  hbar.setValue(new_h)
2522
2863
  vbar.setValue(new_v)
2523
- self._schedule_emit_view_transform()
2864
+
2865
+ # Defer one final smooth redraw (and WCS overlay) after the burst
2866
+ self._request_zoom_redraw()
2867
+
2868
+
2869
+ def _request_zoom_redraw(self):
2870
+ if getattr(self, "_zoom_timer", None) is None:
2871
+ self._zoom_timer = QTimer(self)
2872
+ self._zoom_timer.setSingleShot(True)
2873
+ self._zoom_timer.timeout.connect(self._apply_zoom_redraw)
2874
+
2875
+ # 60–120ms feels better than 16ms for “zoom burst collapse”
2876
+ # but keep your 16ms if you prefer.
2877
+ self._zoom_timer.start(90)
2878
+
2879
+
2880
+ def _apply_zoom_redraw(self):
2881
+ """
2882
+ Final “settled” redraw:
2883
+ - SmoothTransformation
2884
+ - Optional WCS grid overlay
2885
+ """
2886
+ if getattr(self, "_pm_src", None) is None:
2887
+ return
2888
+ self._present_scaled(interactive=False)
2889
+
2890
+
2891
+
2892
+ def has_active_preview(self) -> bool:
2893
+ return self._active_source_kind == "preview" and self._active_preview_id is not None
2894
+
2895
+ def current_preview_roi(self) -> tuple[int,int,int,int] | None:
2896
+ """
2897
+ Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
2898
+ """
2899
+ if not self.has_active_preview():
2900
+ return None
2901
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2902
+ return None if src is None else tuple(src["roi"])
2903
+
2904
+ def current_preview_name(self) -> str | None:
2905
+ if not self.has_active_preview():
2906
+ return None
2907
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2908
+ return None if src is None else src["name"]
2909
+
2910
+
2524
2911
 
2525
2912
  def _find_main_window(self):
2526
2913
  p = self.parent()
@@ -2653,37 +3040,76 @@ class ImageSubWindow(QWidget):
2653
3040
  return True
2654
3041
  return False
2655
3042
 
3043
+ sw = self._mdi_subwindow()
3044
+ if sw is not None and obj is sw:
3045
+ et = ev.type()
3046
+ if et in (QEvent.Type.WindowStateChange, QEvent.Type.Show, QEvent.Type.Resize):
3047
+ QTimer.singleShot(0, self._update_inline_title_and_buttons)
3048
+
2656
3049
  return super().eventFilter(obj, ev)
2657
3050
 
3051
+ def _viewport_pos_to_image_xy(self, vp_pos: QPoint) -> tuple[int, int] | None:
3052
+ """
3053
+ Convert a point in viewport coordinates to FULL image pixel coordinates.
3054
+ Returns None if the point is outside the displayed pixmap (in margins).
3055
+ """
3056
+ pm = self.label.pixmap()
3057
+ if pm is None:
3058
+ return None
3059
+
3060
+ # Convert viewport point into label coordinates
3061
+ p_label = self.label.mapFrom(self.scroll.viewport(), vp_pos)
3062
+
3063
+ # If label is larger than pixmap, pixmap may be centered inside label.
3064
+ pm_w, pm_h = pm.width(), pm.height()
3065
+ lbl_w, lbl_h = self.label.width(), self.label.height()
3066
+
3067
+ off_x = max(0, (lbl_w - pm_w) // 2)
3068
+ off_y = max(0, (lbl_h - pm_h) // 2)
3069
+
3070
+ px = p_label.x() - off_x
3071
+ py = p_label.y() - off_y
3072
+
3073
+ # Outside the drawn pixmap area → clamp
3074
+ px = max(0, min(px, pm_w - 1))
3075
+ py = max(0, min(py, pm_h - 1))
3076
+
3077
+ s = max(float(self.scale), 1e-12)
3078
+
3079
+ # pixmap pixels -> image pixels (pm = image * scale)
3080
+ xi = int(round(px / s))
3081
+ yi = int(round(py / s))
3082
+ return xi, yi
3083
+
2658
3084
 
2659
3085
  def _finish_preview_rect(self, vp_rect: QRect):
2660
- # Map viewport rectangle into image coordinates
2661
3086
  if vp_rect.width() < 4 or vp_rect.height() < 4:
2662
3087
  self._cancel_rubber()
2663
3088
  return
2664
3089
 
2665
- hbar = self.scroll.horizontalScrollBar()
2666
- vbar = self.scroll.verticalScrollBar()
3090
+ # Convert the two corners from viewport space to image space
3091
+ p0 = self._viewport_pos_to_image_xy(vp_rect.topLeft())
3092
+ p1 = self._viewport_pos_to_image_xy(vp_rect.bottomRight())
2667
3093
 
2668
- # Upper-left in label coords
2669
- x_label0 = hbar.value() + vp_rect.left()
2670
- y_label0 = vbar.value() + vp_rect.top()
2671
- x_label1 = hbar.value() + vp_rect.right()
2672
- y_label1 = vbar.value() + vp_rect.bottom()
3094
+ if p0 is None or p1 is None:
3095
+ # User dragged into margins; you can either cancel or clamp.
3096
+ # Cancel is simplest:
3097
+ self._cancel_rubber()
3098
+ return
2673
3099
 
2674
- s = max(self.scale, 1e-12)
3100
+ x0, y0 = p0
3101
+ x1, y1 = p1
2675
3102
 
2676
- x0 = int(round(x_label0 / s))
2677
- y0 = int(round(y_label0 / s))
2678
- x1 = int(round(x_label1 / s))
2679
- y1 = int(round(y_label1 / s))
3103
+ x = min(x0, x1)
3104
+ y = min(y0, y1)
3105
+ w = abs(x1 - x0)
3106
+ h = abs(y1 - y0)
2680
3107
 
2681
- if x1 <= x0 or y1 <= y0:
3108
+ if w < 1 or h < 1:
2682
3109
  self._cancel_rubber()
2683
3110
  return
2684
3111
 
2685
- roi = (x0, y0, x1 - x0, y1 - y0)
2686
- self._create_preview_from_roi(roi)
3112
+ self._create_preview_from_roi((x, y, w, h))
2687
3113
  self._cancel_rubber()
2688
3114
 
2689
3115
  def _create_preview_from_roi(self, roi: tuple[int,int,int,int]):