setiastrosuitepro 1.6.0__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 (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,3267 @@
1
+ # pro/subwindow.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtCore import Qt, QPoint, pyqtSignal, QSize, QEvent, QByteArray, QMimeData, QSettings, QTimer, QRect, QPoint, QMargins
4
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollArea, QLabel, QToolButton, QHBoxLayout, QMessageBox, QMdiSubWindow, QMenu, QInputDialog, QApplication, QTabWidget, QRubberBand
5
+ from PyQt6.QtGui import QPixmap, QImage, QWheelEvent, QShortcut, QKeySequence, QCursor, QDrag, QGuiApplication
6
+ from PyQt6 import sip
7
+ import numpy as np
8
+ import json
9
+ import math
10
+ import weakref
11
+ import os
12
+ try:
13
+ from PyQt6.QtCore import QSignalBlocker
14
+ except Exception:
15
+ class QSignalBlocker:
16
+ def __init__(self, obj): self.obj = obj
17
+ def __enter__(self):
18
+ try: self.obj.blockSignals(True)
19
+ except Exception as e:
20
+ import logging
21
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
22
+ def __exit__(self, *exc):
23
+ try: self.obj.blockSignals(False)
24
+ except Exception as e:
25
+ import logging
26
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
27
+
28
+ from .autostretch import autostretch # ← uses pro/imageops/stretch.py
29
+
30
+ from setiastro.saspro.dnd_mime import MIME_VIEWSTATE, MIME_MASK, MIME_ASTROMETRY, MIME_CMD, MIME_LINKVIEW
31
+ from setiastro.saspro.shortcuts import _unpack_cmd_payload
32
+ from setiastro.saspro.widgets.image_utils import ensure_contiguous
33
+
34
+ from .layers import composite_stack, ImageLayer, BLEND_MODES
35
+
36
+ # --- NEW: simple table model for TableDocument ---
37
+ from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt, QVariant
38
+
39
+ __all__ = ["ImageSubWindow", "TableSubWindow"]
40
+
41
+ class SimpleTableModel(QAbstractTableModel):
42
+ def __init__(self, rows: list[list], headers: list[str], parent=None):
43
+ super().__init__(parent)
44
+ self._rows = rows
45
+ self._headers = headers
46
+
47
+ def rowCount(self, parent=QModelIndex()) -> int:
48
+ return 0 if parent.isValid() else len(self._rows)
49
+
50
+ def columnCount(self, parent=QModelIndex()) -> int:
51
+ return 0 if parent.isValid() else (len(self._headers) if self._headers else (len(self._rows[0]) if self._rows else 0))
52
+
53
+ def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
54
+ if not index.isValid():
55
+ return QVariant()
56
+ if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
57
+ try:
58
+ return str(self._rows[index.row()][index.column()])
59
+ except Exception:
60
+ return ""
61
+ return QVariant()
62
+
63
+ def headerData(self, section: int, orientation: Qt.Orientation, role=Qt.ItemDataRole.DisplayRole):
64
+ if role != Qt.ItemDataRole.DisplayRole:
65
+ return QVariant()
66
+ if orientation == Qt.Orientation.Horizontal:
67
+ try:
68
+ return self._headers[section] if self._headers and 0 <= section < len(self._headers) else f"C{section+1}"
69
+ except Exception:
70
+ return f"C{section+1}"
71
+ else:
72
+ return str(section + 1)
73
+
74
+
75
+ class _DragTab(QLabel):
76
+ """
77
+ Little grab tab you can drag to copy/sync view state.
78
+ - Drag onto MDI background → duplicate view (same document)
79
+ - Drag onto another subwindow → copy zoom/pan/stretch to that view
80
+ """
81
+ def __init__(self, owner, *args, **kwargs):
82
+ super().__init__(*args, **kwargs)
83
+ self.owner = owner
84
+ self._press_pos = None
85
+ self.setText("⧉")
86
+ self.setToolTip(
87
+ "Drag to duplicate/copy view.\n"
88
+ "Hold Alt while dragging to LINK this view with another (live pan/zoom sync).\n"
89
+ "Hold Shift while dragging to drop this image as a mask onto another view.\n"
90
+ "Hold Ctrl while dragging to copy the astrometric solution (WCS) to another view."
91
+ )
92
+
93
+ self.setFixedSize(22, 18)
94
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
95
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
96
+ self.setStyleSheet(
97
+ "QLabel{background:rgba(255,255,255,30); "
98
+ "border:1px solid rgba(255,255,255,60); border-radius:4px;}"
99
+ )
100
+
101
+ def mousePressEvent(self, ev):
102
+ if ev.button() == Qt.MouseButton.LeftButton:
103
+ self._press_pos = ev.position()
104
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
105
+
106
+
107
+ def mouseMoveEvent(self, ev):
108
+ if self._press_pos is None:
109
+ return
110
+ if (ev.position() - self._press_pos).manhattanLength() > 6:
111
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
112
+ self._press_pos = None
113
+ mods = QApplication.keyboardModifiers()
114
+ if (mods & Qt.KeyboardModifier.AltModifier):
115
+ self.owner._start_link_drag()
116
+ elif (mods & Qt.KeyboardModifier.ShiftModifier):
117
+ print("[DragTab] Shift+drag → start_mask_drag() from", id(self.owner))
118
+ self.owner._start_mask_drag()
119
+ elif (mods & Qt.KeyboardModifier.ControlModifier):
120
+ self.owner._start_astrometry_drag()
121
+ else:
122
+ self.owner._start_viewstate_drag()
123
+
124
+ def mouseReleaseEvent(self, ev):
125
+ self._press_pos = None
126
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
127
+
128
+ MASK_GLYPH = "■"
129
+ #ACTIVE_PREFIX = "Active View: "
130
+ ACTIVE_PREFIX = ""
131
+ GLYPHS = "■●◆▲▪▫•◼◻◾◽🔗"
132
+ LINK_PREFIX = "🔗 "
133
+ DECORATION_PREFIXES = (
134
+ LINK_PREFIX, # "🔗 "
135
+ f"{MASK_GLYPH} ", # "■ "
136
+ "Active View: ", # legacy
137
+ )
138
+
139
+
140
+ from astropy.wcs import WCS as _AstroWCS
141
+ from astropy.io.fits import Header as _FitsHeader
142
+
143
+ def build_celestial_wcs(header) -> _AstroWCS | None:
144
+ """
145
+ Given a FITS-like header or a dict with FITS keywords, return a *2-D celestial*
146
+ astropy.wcs.WCS. Returns None if a sane celestial WCS cannot be recovered.
147
+ Resilient to 3rd axes (RGB/STOKES) and SIP distortions.
148
+
149
+ Accepted `header`:
150
+ * astropy.io.fits.Header
151
+ * dict of FITS cards (string->value)
152
+ * dict containing {"FITSKeywords": {NAME: [{value: ..., comment: ...}], ...}}
153
+ """
154
+ if header is None:
155
+ return None
156
+
157
+ # (A) If we already got a WCS, try to coerce to celestial
158
+ if isinstance(header, _AstroWCS):
159
+ try:
160
+ wc = getattr(header, "celestial", None)
161
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else header
162
+ except Exception:
163
+ return header
164
+
165
+ # (B) Ensure we have a bona-fide FITS Header
166
+ hdr_obj = None
167
+ if isinstance(header, _FitsHeader):
168
+ hdr_obj = header
169
+ elif isinstance(header, dict):
170
+ # XISF-style: {"FITSKeywords": {"CTYPE1":[{"value":"RA---TAN"}], ...}}
171
+ if "FITSKeywords" in header and isinstance(header["FITSKeywords"], dict):
172
+ from astropy.io.fits import Header
173
+ hdr_obj = Header()
174
+ for k, v in header["FITSKeywords"].items():
175
+ if isinstance(v, list) and v:
176
+ val = v[0].get("value")
177
+ com = v[0].get("comment", "")
178
+ if val is not None:
179
+ try: hdr_obj[str(k)] = (val, com)
180
+ except Exception: hdr_obj[str(k)] = val
181
+ elif v is not None:
182
+ try: hdr_obj[str(k)] = v
183
+ except Exception as e:
184
+ import logging
185
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
186
+ else:
187
+ # Flat dict of FITS-like cards
188
+ from astropy.io.fits import Header
189
+ hdr_obj = Header()
190
+ for k, v in header.items():
191
+ try: hdr_obj[str(k)] = v
192
+ except Exception as e:
193
+ import logging
194
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
195
+
196
+ if hdr_obj is None:
197
+ return None
198
+
199
+ # (C) Try full WCS first
200
+ try:
201
+ w = _AstroWCS(hdr_obj, relax=True)
202
+ wc = getattr(w, "celestial", None)
203
+ if wc is not None and getattr(wc, "naxis", 2) == 2:
204
+ return wc
205
+ if getattr(w, "has_celestial", False):
206
+ return w.celestial
207
+ except Exception:
208
+ w = None
209
+
210
+ # (D) Force a 2-axis interpretation (drop e.g. RGB axis)
211
+ try:
212
+ w2 = _AstroWCS(hdr_obj, relax=True, naxis=2)
213
+ if getattr(w2, "has_celestial", False):
214
+ return w2.celestial
215
+ except Exception:
216
+ pass
217
+
218
+ # (E) As a last resort, scrub obvious axis-3 cards and retry
219
+ try:
220
+ hdr2 = hdr_obj.copy()
221
+ for k in ("CTYPE3","CUNIT3","CRVAL3","CRPIX3",
222
+ "CD3_1","CD3_2","CD3_3","PC3_1","PC3_2","PC3_3"):
223
+ if k in hdr2:
224
+ del hdr2[k]
225
+ w3 = _AstroWCS(hdr2, relax=True, naxis=2)
226
+ if getattr(w3, "has_celestial", False):
227
+ return w3.celestial
228
+ except Exception:
229
+ pass
230
+
231
+ return None
232
+
233
+ def _compute_cropped_wcs(parent_hdr_like, x, y, w, h):
234
+ """
235
+ Build a cropped WCS header from parent_hdr_like and ROI (x,y,w,h).
236
+
237
+ IMPORTANT:
238
+ - If the parent header already describes a cropped ROI (NAXIS1/2 already
239
+ equal to w/h, or the ROI is obviously outside the parent NAXIS), we
240
+ *do not* shift CRPIX again. We just return a copy of the parent header,
241
+ marking it as ROI-CROP if needed.
242
+ """
243
+ # Normalize ROI values to ints
244
+ x = int(x)
245
+ y = int(y)
246
+ w = int(w)
247
+ h = int(h)
248
+
249
+ # Same helper as before; safe on dict/FITS Header
250
+ try:
251
+ from astropy.io.fits import Header
252
+ except Exception:
253
+ Header = None
254
+
255
+ if Header is not None and isinstance(parent_hdr_like, Header):
256
+ base = {k: parent_hdr_like.get(k) for k in parent_hdr_like.keys()}
257
+ elif isinstance(parent_hdr_like, dict):
258
+ fk = parent_hdr_like.get("FITSKeywords")
259
+ if isinstance(fk, dict) and fk:
260
+ base = {}
261
+ for k, arr in fk.items():
262
+ try:
263
+ base[k] = (arr or [{}])[0].get("value", None)
264
+ except Exception:
265
+ pass
266
+ else:
267
+ base = dict(parent_hdr_like)
268
+ else:
269
+ base = {}
270
+
271
+ # ------------------------------------------------------------------
272
+ # Detect "already cropped" headers to avoid double-shifting CRPIX.
273
+ # ------------------------------------------------------------------
274
+ nax1 = base.get("NAXIS1")
275
+ nax2 = base.get("NAXIS2")
276
+
277
+ if isinstance(nax1, (int, float)) and isinstance(nax2, (int, float)):
278
+ n1 = int(nax1)
279
+ n2 = int(nax2)
280
+
281
+ # Case A: parent already has same size as requested ROI,
282
+ # but x,y are non-zero → this smells like ROI-of-ROI.
283
+ if w == n1 and h == n2 and (x != 0 or y != 0):
284
+
285
+ base["NAXIS1"], base["NAXIS2"] = n1, n2
286
+ base.setdefault("CROPX", 0)
287
+ base.setdefault("CROPY", 0)
288
+ base.setdefault("SASKIND", "ROI-CROP")
289
+ return base
290
+
291
+ # Case B: ROI clearly outside parent dimensions → also treat as
292
+ # "already cropped, don't touch CRPIX".
293
+ if x >= n1 or y >= n2 or x + w > n1 or y + h > n2:
294
+
295
+ base["NAXIS1"], base["NAXIS2"] = n1, n2
296
+ base.setdefault("CROPX", 0)
297
+ base.setdefault("CROPY", 0)
298
+ base.setdefault("SASKIND", "ROI-CROP")
299
+ return base
300
+
301
+ # ------------------------------------------------------------------
302
+ # Normal behavior: real crop relative to full-frame parent.
303
+ # ------------------------------------------------------------------
304
+ c1, c2 = base.get("CRPIX1"), base.get("CRPIX2")
305
+ if isinstance(c1, (int, float)) and isinstance(c2, (int, float)):
306
+ base["CRPIX1"] = float(c1) - float(x)
307
+ base["CRPIX2"] = float(c2) - float(y)
308
+
309
+ base["NAXIS1"], base["NAXIS2"] = w, h
310
+ base["CROPX"], base["CROPY"] = x, y
311
+ base["SASKIND"] = "ROI-CROP"
312
+ return base
313
+
314
+
315
+
316
+ class ImageSubWindow(QWidget):
317
+ aboutToClose = pyqtSignal(object)
318
+ autostretchChanged = pyqtSignal(bool)
319
+ requestDuplicate = pyqtSignal(object) # document
320
+ layers_changed = pyqtSignal()
321
+ autostretchProfileChanged = pyqtSignal(str)
322
+ viewTitleChanged = pyqtSignal(object, str)
323
+ activeSourceChanged = pyqtSignal(object) # None for full, or (x,y,w,h) for ROI
324
+ viewTransformChanged = pyqtSignal(float, int, int)
325
+ _registry = weakref.WeakValueDictionary()
326
+ resized = pyqtSignal()
327
+ replayOnBaseRequested = pyqtSignal(object)
328
+
329
+
330
+ def __init__(self, document, parent=None):
331
+ super().__init__(parent)
332
+ self._base_document = None
333
+ self.document = document
334
+ self._last_title_for_emit = None
335
+
336
+ # ─────────────────────────────────────────────────────────
337
+ # View / render state
338
+ # ─────────────────────────────────────────────────────────
339
+ self._min_scale = 0.02
340
+ self._max_scale = 3.00 # 300%
341
+ self.scale = 0.25
342
+ self._dragging = False
343
+ self._drag_start = QPoint()
344
+ self._autostretch_linked = QSettings().value("display/stretch_linked", False, type=bool)
345
+ self.autostretch_enabled = False
346
+ self.autostretch_target = 0.25
347
+ self.autostretch_sigma = 3.0
348
+ self.autostretch_profile = "normal"
349
+ self.show_mask_overlay = False
350
+ self._mask_overlay_alpha = 0.5 # 0..1
351
+ self._mask_overlay_invert = True
352
+ self._layers: list[ImageLayer] = []
353
+ self.layers_changed.connect(lambda: None)
354
+ self._display_override: np.ndarray | None = None
355
+ self._readout_hint_shown = False
356
+ self._link_emit_timer = QTimer(self)
357
+ self._link_emit_timer.setSingleShot(True)
358
+ self._link_emit_timer.setInterval(100) # tweak 120–250ms to taste
359
+ self._link_emit_timer.timeout.connect(self._emit_view_transform_now)
360
+ self._suppress_link_emit = False # guard while applying remote updates
361
+ self._link_squelch = False # prevents feedback on linked apply
362
+ self._pan_live = False
363
+ self._linked_views = weakref.WeakSet()
364
+ ImageSubWindow._registry[id(self)] = self
365
+ self._link_badge_on = False
366
+
367
+
368
+
369
+ # whenever we move/zoom, relay to linked peers
370
+ self.viewTransformChanged.connect(self._relay_to_linked)
371
+ # pixel readout live-probe state
372
+ self._space_down = False
373
+ self._readout_dragging = False
374
+ self._last_readout = None
375
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
376
+
377
+ # Title (doc/view) sync
378
+ self._view_title_override = None
379
+ self.document.changed.connect(self._sync_host_title)
380
+ self._sync_host_title()
381
+ self.document.changed.connect(self._refresh_local_undo_buttons)
382
+
383
+ # Cached display buffer
384
+ self._buf8 = None # backing np.uint8 [H,W,3]
385
+ self._qimg_src = None # QImage wrapping _buf8
386
+
387
+ # Keep mask visuals in sync when doc changes
388
+ self.document.changed.connect(self._on_doc_mask_changed)
389
+
390
+ # ─────────────────────────────────────────────────────────
391
+ # Preview tabs state
392
+ # ─────────────────────────────────────────────────────────
393
+ self._tabs: QTabWidget | None = None
394
+ self._previews: list[dict] = [] # {"id": int, "name": str, "roi": (x,y,w,h), "arr": np.ndarray}
395
+ self._active_source_kind = "full" # "full" | "preview"
396
+ self._active_preview_id: int | None = None
397
+ self._next_preview_id = 1
398
+
399
+ # Rubber-band / selection for previews
400
+ self._preview_select_mode = False
401
+ self._rubber: QRubberBand | None = None
402
+ self._rubber_origin: QPoint | None = None
403
+
404
+ # ─────────────────────────────────────────────────────────
405
+ # UI construction
406
+ # ─────────────────────────────────────────────────────────
407
+ lyt = QVBoxLayout(self)
408
+
409
+ # Top row: drag-tab + Preview button
410
+ row = QHBoxLayout()
411
+ row.setContentsMargins(0, 0, 0, 0)
412
+ self._drag_tab = _DragTab(self)
413
+ row.addWidget(self._drag_tab, 0, Qt.AlignmentFlag.AlignLeft)
414
+
415
+ self._preview_btn = QToolButton(self)
416
+ self._preview_btn.setText("⟂") # crosshair glyph
417
+ self._preview_btn.setToolTip("Create Preview: click, then drag on the image to define a preview rectangle.")
418
+ self._preview_btn.setCheckable(True)
419
+ self._preview_btn.clicked.connect(self._toggle_preview_select_mode)
420
+ row.addWidget(self._preview_btn, 0, Qt.AlignmentFlag.AlignLeft)
421
+ # — Undo / Redo just for this subwindow —
422
+ self._btn_undo = QToolButton(self)
423
+ self._btn_undo.setText("↶") # or use an icon
424
+ self._btn_undo.setToolTip("Undo (this view)")
425
+ self._btn_undo.setEnabled(False)
426
+ self._btn_undo.clicked.connect(self._on_local_undo)
427
+ row.addWidget(self._btn_undo, 0, Qt.AlignmentFlag.AlignLeft)
428
+
429
+ self._btn_redo = QToolButton(self)
430
+ self._btn_redo.setText("↷")
431
+ self._btn_redo.setToolTip("Redo (this view)")
432
+ self._btn_redo.setEnabled(False)
433
+ self._btn_redo.clicked.connect(self._on_local_redo)
434
+ row.addWidget(self._btn_redo, 0, Qt.AlignmentFlag.AlignLeft)
435
+
436
+ self._btn_replay_main = QToolButton(self)
437
+ self._btn_replay_main.setText("⟳") # pick any glyph you like
438
+ self._btn_replay_main.setToolTip(
439
+ "Click: replay the last action on the base image.\n"
440
+ "Arrow: pick a specific past action to replay on the base image."
441
+ )
442
+ self._btn_replay_main.setEnabled(False) # enabled only when preview + history
443
+
444
+ # Left-click = your existing 'replay last on base'
445
+ self._btn_replay_main.clicked.connect(self._on_replay_last_clicked)
446
+
447
+ # NEW: dropdown menu listing all replayable actions
448
+ self._replay_menu = QMenu(self)
449
+ self._btn_replay_main.setMenu(self._replay_menu)
450
+ self._btn_replay_main.setPopupMode(
451
+ QToolButton.ToolButtonPopupMode.MenuButtonPopup
452
+ )
453
+
454
+ row.addWidget(self._btn_replay_main, 0, Qt.AlignmentFlag.AlignLeft)
455
+
456
+
457
+ # ── NEW: WCS grid toggle ─────────────────────────────────────────
458
+ self._btn_wcs = QToolButton(self)
459
+ self._btn_wcs.setText("⌗")
460
+ self._btn_wcs.setToolTip("Toggle WCS grid overlay (if WCS exists)")
461
+ self._btn_wcs.setCheckable(True)
462
+
463
+ # Start OFF on every new view, regardless of WCS presence or past sessions
464
+ self._show_wcs_grid = False
465
+ self._btn_wcs.setChecked(False)
466
+
467
+ self._btn_wcs.toggled.connect(self._on_toggle_wcs_grid)
468
+ row.addWidget(self._btn_wcs, 0, Qt.AlignmentFlag.AlignLeft)
469
+ # ─────────────────────────────────────────────────────────────────
470
+
471
+ row.addStretch(1)
472
+ lyt.addLayout(row)
473
+
474
+ # QTabWidget that hosts "Full" (real viewer) + any Preview tabs (placeholder widgets)
475
+ self._tabs = QTabWidget(self)
476
+ self._tabs.setTabsClosable(True)
477
+ self._tabs.setDocumentMode(True)
478
+ self._tabs.setMovable(True)
479
+
480
+ # Build the default "Full" tab, which contains the ONE real viewer (scroll+label)
481
+ full_host = QWidget(self)
482
+ full_v = QVBoxLayout(full_host)
483
+ full_v.setContentsMargins(0, 0, 0, 0)
484
+
485
+ self.scroll = QScrollArea(full_host)
486
+ self.scroll.setWidgetResizable(False)
487
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
488
+ self.scroll.setWidget(self.label)
489
+ self.scroll.viewport().setMouseTracking(True)
490
+ self.label.setMouseTracking(True)
491
+ full_v.addWidget(self.scroll)
492
+
493
+ hbar = self.scroll.horizontalScrollBar()
494
+ vbar = self.scroll.verticalScrollBar()
495
+ for bar in (hbar, vbar):
496
+ bar.valueChanged.connect(self._on_scroll_changed)
497
+ bar.sliderMoved.connect(self._on_scroll_changed)
498
+ bar.actionTriggered.connect(self._on_scroll_changed)
499
+
500
+ # IMPORTANT: add the tab BEFORE connecting signals so currentChanged can't fire early
501
+ self._full_tab_idx = self._tabs.addTab(full_host, "Full")
502
+ self._full_host = full_host
503
+ self._tabs.tabBar().setVisible(False) # hidden until a preview exists
504
+ lyt.addWidget(self._tabs)
505
+
506
+ # Now it’s safe to connect
507
+ self._tabs.tabCloseRequested.connect(self._on_tab_close_requested)
508
+ self._tabs.currentChanged.connect(self._on_tab_changed)
509
+ self._tabs.currentChanged.connect(lambda _=None: self._refresh_local_undo_buttons())
510
+
511
+ # DnD + event filters for the single viewer
512
+ self.setAcceptDrops(True)
513
+ self.scroll.viewport().installEventFilter(self)
514
+ self.label.installEventFilter(self)
515
+
516
+ # Context menu + shortcuts
517
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
518
+ self.customContextMenuRequested.connect(self._show_ctx_menu)
519
+ QShortcut(QKeySequence("F2"), self, activated=self._rename_view)
520
+ #QShortcut(QKeySequence("A"), self, activated=self.toggle_autostretch)
521
+ QShortcut(QKeySequence("Ctrl+Space"), self, activated=self.toggle_autostretch)
522
+ QShortcut(QKeySequence("Alt+Shift+A"), self, activated=self.toggle_autostretch)
523
+ QShortcut(QKeySequence("Ctrl+K"), self, activated=self.toggle_mask_overlay)
524
+
525
+ # Re-render when the document changes
526
+ self.document.changed.connect(lambda: self._render(rebuild=True))
527
+ self._render(rebuild=True)
528
+ QTimer.singleShot(0, self._maybe_announce_readout_help)
529
+ self._refresh_local_undo_buttons()
530
+ self._update_replay_button()
531
+
532
+ hbar = self.scroll.horizontalScrollBar()
533
+ vbar = self.scroll.verticalScrollBar()
534
+
535
+ for bar in (hbar, vbar):
536
+ bar.valueChanged.connect(self._schedule_emit_view_transform)
537
+ bar.sliderMoved.connect(lambda _=None: self._schedule_emit_view_transform())
538
+ bar.actionTriggered.connect(lambda _=None: self._schedule_emit_view_transform())
539
+
540
+ # Mask/title adornments
541
+ self._mask_dot_enabled = self._active_mask_array() is not None
542
+ self._active_title_prefix = False
543
+ self._rebuild_title()
544
+
545
+ # Track docs used by layer stack (if any)
546
+ self._watched_docs = set()
547
+ self._history_doc = None
548
+ self._install_history_watchers()
549
+
550
+ # ----- link drag payload -----
551
+ def _start_link_drag(self):
552
+ """
553
+ Alt + drag from ⧉: start a 'link these two views' drag.
554
+ """
555
+ payload = {
556
+ "source_view_id": id(self),
557
+ }
558
+ # identity hints (not strictly required, but nice to have)
559
+ try:
560
+ payload.update(self._drag_identity_fields())
561
+ except Exception:
562
+ pass
563
+
564
+ md = QMimeData()
565
+ md.setData(MIME_LINKVIEW, QByteArray(json.dumps(payload).encode("utf-8")))
566
+ drag = QDrag(self)
567
+ drag.setMimeData(md)
568
+ if self.label.pixmap():
569
+ drag.setPixmap(self.label.pixmap().scaled(
570
+ 64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
571
+ drag.setHotSpot(QPoint(16, 16))
572
+ drag.exec(Qt.DropAction.CopyAction)
573
+
574
+ # ----- link management -----
575
+ def link_to(self, other: "ImageSubWindow"):
576
+ if other is self or other in self._linked_views:
577
+ return
578
+
579
+ # Gather the full sets (including each endpoint)
580
+ a_group = set(self._linked_views) | {self}
581
+ b_group = set(other._linked_views) | {other}
582
+ merged = a_group | b_group
583
+
584
+ # Clear old badges so we can reapply cleanly
585
+ for v in merged:
586
+ try:
587
+ v._linked_views.discard(v) # no-op safety
588
+ except Exception:
589
+ pass
590
+
591
+ # Fully connect everyone to everyone
592
+ for v in merged:
593
+ v._linked_views.update(merged - {v})
594
+ try:
595
+ v._set_link_badge(True)
596
+ except Exception:
597
+ pass
598
+
599
+ # Snap everyone to the initiator’s transform immediately
600
+ try:
601
+ s, h, v = self._current_transform()
602
+ for peer in merged - {self}:
603
+ peer.set_view_transform(s, h, v, from_link=True)
604
+ except Exception:
605
+ pass
606
+
607
+
608
+ def unlink_from(self, other: "ImageSubWindow"):
609
+ if other in self._linked_views:
610
+ self._linked_views.discard(other)
611
+ other._linked_views.discard(self)
612
+ # clear badge if both are now free
613
+ if not self._linked_views:
614
+ self._set_link_badge(False)
615
+ if not other._linked_views:
616
+ other._set_link_badge(False)
617
+
618
+ def unlink_all(self):
619
+ peers = list(self._linked_views)
620
+ for p in peers:
621
+ self.unlink_from(p)
622
+
623
+ def _relay_to_linked(self, scale: float, h: int, v: int):
624
+ """
625
+ When this view pans/zooms, nudge all linked peers. Guarded to avoid loops.
626
+ """
627
+ for peer in list(self._linked_views):
628
+ try:
629
+ peer.set_view_transform(scale, h, v, from_link=True)
630
+ except Exception:
631
+ pass
632
+
633
+ def _set_link_badge(self, on: bool):
634
+ self._link_badge_on = bool(on)
635
+ self._rebuild_title()
636
+
637
+ def _on_scroll_changed(self, *_):
638
+ if self._suppress_link_emit:
639
+ return
640
+ # If we’re actively dragging, emit immediately for realtime follow
641
+ if self._dragging or self._pan_live:
642
+ self._emit_view_transform_now()
643
+ else:
644
+ self._schedule_emit_view_transform()
645
+
646
+ def _current_transform(self):
647
+ hbar = self.scroll.horizontalScrollBar()
648
+ vbar = self.scroll.verticalScrollBar()
649
+ return float(self.scale), int(hbar.value()), int(vbar.value())
650
+
651
+ def _emit_view_transform(self):
652
+ try:
653
+ h = int(self.scroll.horizontalScrollBar().value())
654
+ v = int(self.scroll.verticalScrollBar().value())
655
+ except Exception:
656
+ h = v = 0
657
+ try:
658
+ self.viewTransformChanged.emit(float(self.scale), h, v)
659
+ except Exception:
660
+ pass
661
+
662
+ def _schedule_emit_view_transform(self):
663
+ if self._suppress_link_emit:
664
+ return
665
+ # If we’re in a live pan, don’t debounce—emit now.
666
+ if self._dragging or self._pan_live:
667
+ self._emit_view_transform_now()
668
+ else:
669
+ self._link_emit_timer.start()
670
+
671
+ def _emit_view_transform_now(self):
672
+ if self._suppress_link_emit:
673
+ return
674
+ h = self.scroll.horizontalScrollBar().value()
675
+ v = self.scroll.verticalScrollBar().value()
676
+ try:
677
+ self.viewTransformChanged.emit(float(self.scale), int(h), int(v))
678
+ except Exception:
679
+ pass
680
+
681
+ #------ Replay helpers------
682
+ #------ Replay helpers------
683
+ def _update_replay_button(self):
684
+ """
685
+ Update the 'Replay on main image' button:
686
+
687
+ - Enabled only when a Preview/ROI is active.
688
+ - Populates the dropdown menu with all headless-history entries
689
+ from the main window (newest first).
690
+ """
691
+ btn = getattr(self, "_btn_replay_main", None)
692
+ if not btn:
693
+ return
694
+
695
+ # Do we have an active preview in this view?
696
+ try:
697
+ has_preview = self.has_active_preview()
698
+ except Exception:
699
+ has_preview = False
700
+
701
+ mw = self._find_main_window()
702
+ menu = getattr(self, "_replay_menu", None)
703
+
704
+ history = []
705
+ has_history = False
706
+
707
+ # Pull history from main window if available
708
+ if mw is not None and hasattr(mw, "get_headless_history"):
709
+ try:
710
+ history = mw.get_headless_history() or []
711
+ has_history = bool(history)
712
+ except Exception:
713
+ history = []
714
+ has_history = False
715
+
716
+ # Rebuild the dropdown menu
717
+ if menu is not None:
718
+ menu.clear()
719
+ if has_history:
720
+ # We want newest actions at the *top* of the menu
721
+ for idx_from_end, entry in enumerate(reversed(history)):
722
+ real_index = len(history) - 1 - idx_from_end # index into original list
723
+
724
+ cid = entry.get("command_id", "") or ""
725
+ desc = entry.get("description") or cid or f"#{real_index+1}"
726
+
727
+ act = menu.addAction(desc)
728
+ if cid and cid != desc:
729
+ act.setToolTip(cid)
730
+
731
+ # Capture the index in a default arg so each action gets its own index
732
+ act.triggered.connect(
733
+ lambda _chk=False, i=real_index: self._replay_history_index(i)
734
+ )
735
+
736
+ # Also allow left-click "last action" when main window still has a last payload
737
+ has_last = bool(mw and getattr(mw, "_last_headless_command", None))
738
+
739
+ enabled = bool(has_preview and (has_history or has_last))
740
+ btn.setEnabled(enabled)
741
+
742
+ # DEBUG:
743
+ try:
744
+ print(
745
+ f"[Replay] _update_replay_button: view id={id(self)} "
746
+ f"enabled={enabled}, has_preview={has_preview}, "
747
+ f"history_len={len(history)}"
748
+ )
749
+ except Exception:
750
+ pass
751
+
752
+ def _replay_history_index(self, index: int):
753
+ """
754
+ Called when the user selects an entry from the replay dropdown.
755
+
756
+ We forward to MainWindow.replay_headless_history_entry_on_base(index, target_sw),
757
+ which reuses the big replay_last_action_on_base() switchboard.
758
+ """
759
+ mw = self._find_main_window()
760
+ if mw is None or not hasattr(mw, "replay_headless_history_entry_on_base"):
761
+ try:
762
+ print("[Replay] _replay_history_index: main window or handler missing")
763
+ except Exception:
764
+ pass
765
+ return
766
+
767
+ target_sw = self._mdi_subwindow()
768
+
769
+ try:
770
+ mw.replay_headless_history_entry_on_base(index, target_sw=target_sw)
771
+ try:
772
+ print(
773
+ f"[Replay] _replay_history_index: index={index}, "
774
+ f"view id={id(self)}, target_sw={id(target_sw) if target_sw else None}"
775
+ )
776
+ except Exception:
777
+ pass
778
+ except Exception as e:
779
+ try:
780
+ print(f"[Replay] _replay_history_index failed: {e}")
781
+ except Exception:
782
+ pass
783
+
784
+
785
+ def _on_replay_last_clicked(self):
786
+ """
787
+ User clicked the ⟳ button *main area* (not the arrow).
788
+
789
+ This still does the old behavior:
790
+ - Emit replayOnBaseRequested(view)
791
+ - Main window then replays the *last* action on the base doc
792
+ for this subwindow (via replay_last_action_on_base).
793
+ """
794
+ # DEBUG: log that the button actually fired
795
+ try:
796
+ roi = None
797
+ if hasattr(self, "has_active_preview") and self.has_active_preview():
798
+ try:
799
+ roi = self.current_preview_roi()
800
+ except Exception:
801
+ roi = None
802
+ print(
803
+ f"[Replay] Button clicked in view id={id(self)}, "
804
+ f"has_active_preview={self.has_active_preview() if hasattr(self, 'has_active_preview') else 'n/a'}, "
805
+ f"roi={roi}"
806
+ )
807
+ except Exception:
808
+ pass
809
+
810
+ # Emit self so the main window can locate our QMdiSubWindow wrapper.
811
+ try:
812
+ print(f"[Replay] Emitting replayOnBaseRequested from view id={id(self)}")
813
+ except Exception:
814
+ pass
815
+ self.replayOnBaseRequested.emit(self)
816
+
817
+
818
+
819
+ def _on_pan_or_zoom_changed(self, *_):
820
+ # Debounce lightly if you want; for now, just emit
821
+ self._emit_view_transform()
822
+
823
+ def set_view_transform(self, scale, hval, vval, from_link=False):
824
+ # Avoid storms while we mutate scrollbars/scale
825
+ self._suppress_link_emit = True
826
+ try:
827
+ scale = float(max(self._min_scale, min(scale, self._max_scale)))
828
+ if abs(scale - self.scale) > 1e-9:
829
+ self.scale = scale
830
+ self._render(rebuild=False)
831
+
832
+ hbar = self.scroll.horizontalScrollBar()
833
+ vbar = self.scroll.verticalScrollBar()
834
+ hv = int(hval); vv = int(vval)
835
+ if hv != hbar.value():
836
+ hbar.setValue(hv)
837
+ if vv != vbar.value():
838
+ vbar.setValue(vv)
839
+ finally:
840
+ self._suppress_link_emit = False
841
+
842
+ # IMPORTANT: if this came from a linked peer, do NOT broadcast again.
843
+ if not from_link:
844
+ self._schedule_emit_view_transform()
845
+
846
+ def _on_toggle_wcs_grid(self, on: bool):
847
+ self._show_wcs_grid = bool(on)
848
+ QSettings().setValue("display/show_wcs_grid", self._show_wcs_grid)
849
+ self._render(rebuild=False) # repaint current frame
850
+
851
+
852
+
853
+ def _install_history_watchers(self):
854
+ # disconnect old history doc
855
+ hd = getattr(self, "_history_doc", None)
856
+ if hd is not None and hasattr(hd, "changed"):
857
+ try:
858
+ hd.changed.disconnect(self._on_history_doc_changed)
859
+ except Exception:
860
+ pass
861
+ # in case older builds were wired directly:
862
+ try:
863
+ hd.changed.disconnect(self._refresh_local_undo_buttons)
864
+ except Exception:
865
+ pass
866
+
867
+ # resolve new history doc (ROI when on Preview tab, else base)
868
+ new_hd = self._resolve_history_doc()
869
+ self._history_doc = new_hd
870
+
871
+ # connect new
872
+ if new_hd is not None and hasattr(new_hd, "changed"):
873
+ try:
874
+ new_hd.changed.connect(self._on_history_doc_changed)
875
+ except Exception:
876
+ pass
877
+
878
+ # make the buttons correct right now
879
+ self._refresh_local_undo_buttons()
880
+
881
+ def _drag_identity_fields(self):
882
+ """
883
+ Returns a dict with identity hints for DnD:
884
+ doc_uid (preferred), base_doc_uid (parent/full), and file_path.
885
+ Falls back gracefully if fields are missing.
886
+ """
887
+ doc = getattr(self, "document", None)
888
+ base = getattr(self, "base_document", None) or doc
889
+
890
+ # If DocManager maps preview/ROI views, prefer the true backing doc as base
891
+ dm = getattr(self, "_docman", None)
892
+ try:
893
+ if dm and hasattr(dm, "get_document_for_view"):
894
+ back = dm.get_document_for_view(self)
895
+ if back is not None:
896
+ base = back
897
+ except Exception:
898
+ pass
899
+
900
+ meta = (getattr(doc, "metadata", None) or {})
901
+ base_meta = (getattr(base, "metadata", None) or {})
902
+
903
+ return {
904
+ "doc_uid": getattr(doc, "uid", None),
905
+ "base_doc_uid": getattr(base, "uid", None),
906
+ "file_path": meta.get("file_path") or base_meta.get("file_path") or "",
907
+ }
908
+
909
+
910
+ def _on_local_undo(self):
911
+ doc = self._resolve_history_doc()
912
+ if not doc or not hasattr(doc, "undo"):
913
+ return
914
+ try:
915
+ doc.undo()
916
+ # most ImageDocument implementations emit changed; belt-and-suspenders:
917
+ if hasattr(doc, "changed"): doc.changed.emit()
918
+ except Exception:
919
+ pass
920
+ # repaint and refresh our buttons
921
+ self._render(rebuild=True)
922
+ self._refresh_local_undo_buttons()
923
+
924
+ def _on_local_redo(self):
925
+ doc = self._resolve_history_doc()
926
+ if not doc or not hasattr(doc, "redo"):
927
+ return
928
+ try:
929
+ doc.redo()
930
+ if hasattr(doc, "changed"): doc.changed.emit()
931
+ except Exception:
932
+ pass
933
+ self._render(rebuild=True)
934
+ self._refresh_local_undo_buttons()
935
+
936
+
937
+ def refresh_preview_roi(self, roi_tuple=None):
938
+ """
939
+ Rebuild the active preview pixmap from the parent document’s data.
940
+ If roi_tuple is provided, it's the updated region (x,y,w,h).
941
+ """
942
+ try:
943
+ if not (hasattr(self, "has_active_preview") and self.has_active_preview()):
944
+ return
945
+
946
+ # Optional: sanity check that roi matches the current preview
947
+ if roi_tuple is not None:
948
+ cur = self.current_preview_roi()
949
+ if not (cur and tuple(map(int, cur)) == tuple(map(int, roi_tuple))):
950
+ return # different preview; no refresh needed
951
+
952
+ # Your own method that (re)generates the preview pixmap from the doc
953
+ if hasattr(self, "rebuild_preview_pixmap") and callable(self.rebuild_preview_pixmap):
954
+ self.rebuild_preview_pixmap()
955
+ elif hasattr(self, "_update_preview_layer") and callable(self._update_preview_layer):
956
+ self._update_preview_layer()
957
+ else:
958
+ # Fallback: repaint
959
+ self.update()
960
+ except Exception:
961
+ pass
962
+
963
+ def refresh_full(self):
964
+ """Full-image redraw hook for non-ROI updates."""
965
+ try:
966
+ if hasattr(self, "rebuild_image_pixmap") and callable(self.rebuild_image_pixmap):
967
+ self.rebuild_image_pixmap()
968
+ else:
969
+ self.update()
970
+ except Exception:
971
+ pass
972
+
973
+ def refresh_preview_region(self, roi):
974
+ """
975
+ roi: (x,y,w,h) in FULL image coords. Rebuild the active Preview tab’s pixmap
976
+ from self.document.image[y:y+h, x:x+w].
977
+ """
978
+ if not (hasattr(self, "has_active_preview") and self.has_active_preview()):
979
+ # No preview active → fall back to full refresh
980
+ if hasattr(self, "refresh_from_document"):
981
+ self.refresh_from_document()
982
+ else:
983
+ self.update()
984
+ return
985
+
986
+ try:
987
+ x, y, w, h = map(int, roi)
988
+ arr = self.document.image[y:y+h, x:x+w]
989
+ # Whatever your existing path is to update the preview tab from an ndarray:
990
+ # e.g., self._set_preview_from_array(arr) or self._update_preview_pixmap(arr)
991
+ if hasattr(self, "_set_preview_from_array"):
992
+ self._set_preview_from_array(arr)
993
+ elif hasattr(self, "update_preview_from_array"):
994
+ self.update_preview_from_array(arr)
995
+ else:
996
+ # Fallback: full refresh if you don’t expose a thin setter
997
+ if hasattr(self, "rebuild_active_preview"):
998
+ self.rebuild_active_preview()
999
+ elif hasattr(self, "refresh_from_document"):
1000
+ self.refresh_from_document()
1001
+ self.update()
1002
+ except Exception:
1003
+ # Safe fallback
1004
+ if hasattr(self, "rebuild_active_preview"):
1005
+ self.rebuild_active_preview()
1006
+ elif hasattr(self, "refresh_from_document"):
1007
+ self.refresh_from_document()
1008
+ else:
1009
+ self.update()
1010
+
1011
+
1012
+ def _ensure_tabs(self):
1013
+ if self._tabs:
1014
+ return
1015
+ self._tabs = QTabWidget(self)
1016
+ self._tabs.setTabsClosable(True)
1017
+ self._tabs.tabCloseRequested.connect(self._on_tab_close_requested)
1018
+ self._tabs.currentChanged.connect(self._on_tab_changed)
1019
+ self._tabs.setDocumentMode(True)
1020
+ self._tabs.setMovable(True)
1021
+
1022
+ # Build the default "Full" tab: it contains your scroll+label
1023
+ full_host = QWidget(self)
1024
+ v = QVBoxLayout(full_host)
1025
+ v.setContentsMargins(QMargins(0,0,0,0))
1026
+ # Reuse your existing scroll/label as the content of the "Full" tab
1027
+ self.scroll = QScrollArea(full_host)
1028
+ self.scroll.setWidgetResizable(False)
1029
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
1030
+ self.scroll.setWidget(self.label)
1031
+ v.addWidget(self.scroll)
1032
+ self._full_tab_idx = self._tabs.addTab(full_host, "Full")
1033
+ self._full_host = full_host
1034
+ self._tabs.tabBar().setVisible(False) # hidden until a first preview exists
1035
+
1036
+ def _on_tab_close_requested(self, idx: int):
1037
+ # Prevent closing "Full"
1038
+ if idx == self._full_tab_idx:
1039
+ return
1040
+ wid = self._tabs.widget(idx)
1041
+ prev_id = getattr(wid, "_preview_id", None)
1042
+
1043
+ # Remove model entry
1044
+ self._previews = [p for p in self._previews if p["id"] != prev_id]
1045
+ # If you closed the active one, fall back to full
1046
+ if self._active_preview_id == prev_id:
1047
+ self._active_source_kind = "full"
1048
+ self._active_preview_id = None
1049
+ self._render(True)
1050
+
1051
+ self._tabs.removeTab(idx)
1052
+ wid.deleteLater()
1053
+
1054
+ # Hide tabs if no more previews
1055
+ if not self._previews:
1056
+ self._tabs.tabBar().setVisible(False)
1057
+
1058
+ self._update_replay_button()
1059
+
1060
+ def _on_tab_changed(self, idx: int):
1061
+ if not hasattr(self, "_full_tab_idx"):
1062
+ return
1063
+ if idx == self._full_tab_idx:
1064
+ self._active_source_kind = "full"
1065
+ self._active_preview_id = None
1066
+ host = getattr(self, "_full_host", None) or self._tabs.widget(idx) # ← safe
1067
+ else:
1068
+ wid = self._tabs.widget(idx)
1069
+ self._active_source_kind = "preview"
1070
+ self._active_preview_id = getattr(wid, "_preview_id", None)
1071
+ host = wid
1072
+
1073
+ if host is not None:
1074
+ self._move_view_into(host)
1075
+ self._install_history_watchers()
1076
+ self._render(True)
1077
+ self._refresh_local_undo_buttons()
1078
+ self._update_replay_button()
1079
+ self._emit_view_transform()
1080
+ mw = self._find_main_window()
1081
+ if mw is not None and getattr(mw, "_auto_fit_on_resize", False):
1082
+ try:
1083
+ mw._zoom_active_fit()
1084
+ except Exception:
1085
+ pass
1086
+
1087
+ def _toggle_preview_select_mode(self, on: bool):
1088
+ self._preview_select_mode = bool(on)
1089
+ self._set_preview_cursor(self._preview_select_mode)
1090
+ if self._preview_select_mode:
1091
+ mw = self._find_main_window()
1092
+ if mw and hasattr(mw, "statusBar"):
1093
+ mw.statusBar().showMessage("Preview mode: drag a rectangle on the image to create a preview.", 6000)
1094
+ else:
1095
+ self._cancel_rubber()
1096
+
1097
+ def _cancel_rubber(self):
1098
+ if self._rubber is not None:
1099
+ self._rubber.hide()
1100
+ self._rubber.deleteLater()
1101
+ self._rubber = None
1102
+ self._rubber_origin = None
1103
+ self._preview_select_mode = False
1104
+ self._set_preview_cursor(False)
1105
+ if self._preview_btn.isChecked():
1106
+ self._preview_btn.setChecked(False)
1107
+
1108
+ def _current_tab_host(self):
1109
+ # returns the QWidget inside the current tab
1110
+ return self._tabs.widget(self._tabs.currentIndex())
1111
+
1112
+ def _move_view_into(self, host_widget: QWidget):
1113
+ """Reparent the single viewer (scroll+label) into host_widget's layout."""
1114
+ if self.scroll.parent() is host_widget:
1115
+ return
1116
+ # take it out of the old parent layout
1117
+ try:
1118
+ old_layout = self.scroll.parentWidget().layout()
1119
+ if old_layout:
1120
+ old_layout.removeWidget(self.scroll)
1121
+ except Exception:
1122
+ pass
1123
+
1124
+ # ensure host has a VBox layout
1125
+ lay = host_widget.layout()
1126
+ if lay is None:
1127
+ from PyQt6.QtWidgets import QVBoxLayout
1128
+ lay = QVBoxLayout(host_widget)
1129
+ lay.setContentsMargins(0, 0, 0, 0)
1130
+
1131
+ # insert viewer; kill any placeholder child labels if present
1132
+ try:
1133
+ kids = host_widget.findChildren(QLabel, options=Qt.FindChildOption.FindDirectChildrenOnly)
1134
+ except Exception:
1135
+ kids = host_widget.findChildren(QLabel) # recursive fallback
1136
+ for ch in list(kids):
1137
+ if ch is not self.label:
1138
+ ch.deleteLater()
1139
+
1140
+ self.scroll.setParent(host_widget)
1141
+ lay.addWidget(self.scroll)
1142
+ self.scroll.show()
1143
+
1144
+ def _set_preview_cursor(self, active: bool):
1145
+ cur = Qt.CursorShape.CrossCursor if active else Qt.CursorShape.ArrowCursor
1146
+ for w in (self, getattr(self, "scroll", None) and self.scroll.viewport(), getattr(self, "label", None)):
1147
+ if not w:
1148
+ continue
1149
+ try:
1150
+ w.unsetCursor() # clear any prior override
1151
+ w.setCursor(cur) # then set desired cursor
1152
+ except Exception:
1153
+ pass
1154
+
1155
+
1156
+ def _maybe_announce_readout_help(self):
1157
+ """Show the readout hint only once automatically."""
1158
+ if self._readout_hint_shown:
1159
+ return
1160
+ self._announce_readout_help()
1161
+ self._readout_hint_shown = True
1162
+
1163
+ def _announce_readout_help(self):
1164
+ mw = self._find_main_window()
1165
+ if mw and hasattr(mw, "statusBar"):
1166
+ sb = mw.statusBar()
1167
+ if sb:
1168
+ sb.showMessage("Press Space + Click/Drag to probe pixels (WCS shown if available)", 8000)
1169
+
1170
+
1171
+
1172
+ def apply_layer_stack(self, layers):
1173
+ """
1174
+ Rebuild the display override from base document + given layer stack.
1175
+ Does not mutate the underlying document.image.
1176
+ """
1177
+ try:
1178
+ base = self.document.image
1179
+ if layers:
1180
+ comp = composite_stack(base, layers)
1181
+ self._display_override = comp
1182
+ else:
1183
+ self._display_override = None
1184
+ self.layers_changed.emit()
1185
+ self._render(rebuild=True)
1186
+ except Exception as e:
1187
+ print("[ImageSubWindow] apply_layer_stack error:", e)
1188
+
1189
+ # --- add to ImageSubWindow ---
1190
+ def _collect_layer_docs(self):
1191
+ docs = set()
1192
+ for L in getattr(self, "_layers", []):
1193
+ d = getattr(L, "src_doc", None)
1194
+ if d is not None:
1195
+ docs.add(d)
1196
+ md = getattr(L, "mask_doc", None)
1197
+ if md is not None:
1198
+ docs.add(md)
1199
+ return docs
1200
+
1201
+ def keyPressEvent(self, ev):
1202
+ if ev.key() == Qt.Key.Key_Space:
1203
+ # only the first time we enter probe mode
1204
+ if not self._space_down and not self._readout_hint_shown:
1205
+ self._announce_readout_help()
1206
+ self._readout_hint_shown = True
1207
+ self._space_down = True
1208
+ ev.accept()
1209
+ return
1210
+ super().keyPressEvent(ev)
1211
+
1212
+
1213
+
1214
+ def keyReleaseEvent(self, ev):
1215
+ if ev.key() == Qt.Key.Key_Space:
1216
+ self._space_down = False
1217
+ # DO NOT stop _readout_dragging here – mouse release will do that
1218
+ ev.accept()
1219
+ return
1220
+ super().keyReleaseEvent(ev)
1221
+
1222
+
1223
+
1224
+ def _sample_image_at_viewport_pos(self, vp_pos: QPoint):
1225
+ """
1226
+ vp_pos: position in viewport coords (the visible part of the scroll area).
1227
+ Returns (x_img_int, y_img_int, sample_dict) or None if OOB.
1228
+ sample_dict is always raw float(s), never normalized.
1229
+ """
1230
+ if self.document is None or self.document.image is None:
1231
+ return None
1232
+
1233
+ arr = np.asarray(self.document.image)
1234
+
1235
+ # detect shape
1236
+ if arr.ndim == 2:
1237
+ h, w = arr.shape
1238
+ channels = 1
1239
+ elif arr.ndim == 3:
1240
+ h, w, channels = arr.shape[:3]
1241
+ else:
1242
+ return None # unsupported shape
1243
+
1244
+ # current scroll offsets
1245
+ hbar = self.scroll.horizontalScrollBar()
1246
+ vbar = self.scroll.verticalScrollBar()
1247
+ x_label = hbar.value() + vp_pos.x()
1248
+ y_label = vbar.value() + vp_pos.y()
1249
+
1250
+ scale = max(self.scale, 1e-12)
1251
+ x_img = x_label / scale
1252
+ y_img = y_label / scale
1253
+
1254
+ xi = int(round(x_img))
1255
+ yi = int(round(y_img))
1256
+
1257
+ if xi < 0 or yi < 0 or xi >= w or yi >= h:
1258
+ return None
1259
+
1260
+ # ---- mono cases ----
1261
+ if arr.ndim == 2 or channels == 1:
1262
+ # pure mono or (H, W, 1)
1263
+ if arr.ndim == 2:
1264
+ val = float(arr[yi, xi])
1265
+ else:
1266
+ val = float(arr[yi, xi, 0])
1267
+ sample = {"mono": val}
1268
+ return (xi, yi, sample)
1269
+
1270
+ # ---- color / 3+ channels ----
1271
+ pix = arr[yi, xi]
1272
+
1273
+ # make robust if pix is 1-D
1274
+ # expect at least 3 numbers, fallback to repeating R
1275
+ r = float(pix[0])
1276
+ g = float(pix[1]) if channels > 1 else r
1277
+ b = float(pix[2]) if channels > 2 else r
1278
+
1279
+ sample = {"r": r, "g": g, "b": b}
1280
+ return (xi, yi, sample)
1281
+
1282
+
1283
+
1284
+ def sizeHint(self) -> QSize:
1285
+ lbl = getattr(self, "image_label", None) or getattr(self, "label", None)
1286
+ sa = getattr(self, "scroll_area", None) or self.findChild(QScrollArea)
1287
+ if lbl and hasattr(lbl, "pixmap") and lbl.pixmap() and not lbl.pixmap().isNull():
1288
+ pm = lbl.pixmap()
1289
+ # logical pixels (HiDPI-safe)
1290
+ dpr = pm.devicePixelRatioF() if hasattr(pm, "devicePixelRatioF") else 1.0
1291
+ pm_w = int(math.ceil(pm.width() / dpr))
1292
+ pm_h = int(math.ceil(pm.height() / dpr))
1293
+
1294
+ # label margins
1295
+ lm = lbl.contentsMargins()
1296
+ w = pm_w + lm.left() + lm.right()
1297
+ h = pm_h + lm.top() + lm.bottom()
1298
+
1299
+ # scrollarea chrome (frame + reserve bar thickness)
1300
+ if sa:
1301
+ fw = sa.frameWidth()
1302
+ w += fw * 2 + sa.verticalScrollBar().sizeHint().width()
1303
+ h += fw * 2 + sa.horizontalScrollBar().sizeHint().height()
1304
+
1305
+ # this widget’s margins
1306
+ m = self.contentsMargins()
1307
+ w += m.left() + m.right() + 2
1308
+ h += m.top() + m.bottom() + 20
1309
+
1310
+ # tiny safety pad so bars never appear from rounding
1311
+ return QSize(w + 2, h + 8)
1312
+
1313
+ return super().sizeHint()
1314
+
1315
+ def _on_layer_source_changed(self):
1316
+ # Any source/mask doc changed → recomposite current stack
1317
+ try:
1318
+ self.apply_layer_stack(self._layers)
1319
+ except Exception as e:
1320
+ print("[ImageSubWindow] _on_layer_source_changed error:", e)
1321
+
1322
+ def _reinstall_layer_watchers(self):
1323
+ # Disconnect old
1324
+ for d in list(self._watched_docs):
1325
+ try:
1326
+ d.changed.disconnect(self._on_layer_source_changed)
1327
+ except Exception:
1328
+ pass
1329
+ # Connect new
1330
+ newdocs = self._collect_layer_docs()
1331
+ for d in newdocs:
1332
+ try:
1333
+ d.changed.connect(self._on_layer_source_changed)
1334
+ except Exception:
1335
+ pass
1336
+ self._watched_docs = newdocs
1337
+
1338
+
1339
+ def toggle_mask_overlay(self):
1340
+ self.show_mask_overlay = not self.show_mask_overlay
1341
+ self._render(rebuild=True)
1342
+
1343
+ def _rebuild_title(self, *, base: str | None = None):
1344
+ sub = self._mdi_subwindow()
1345
+ if not sub: return
1346
+ if base is None:
1347
+ base = self._effective_title() or "Untitled"
1348
+
1349
+ # ✅ strip any carried-over glyphs (🔗, ■, “Active View: ”) from overrides/doc names
1350
+ core, _ = self._strip_decorations(base)
1351
+
1352
+ title = core
1353
+ if getattr(self, "_link_badge_on", False):
1354
+ title = f"{LINK_PREFIX}{title}"
1355
+ if self._mask_dot_enabled:
1356
+ title = f"{MASK_GLYPH} {title}"
1357
+
1358
+ if title != sub.windowTitle():
1359
+ sub.setWindowTitle(title)
1360
+ sub.setToolTip(title)
1361
+ if title != self._last_title_for_emit:
1362
+ self._last_title_for_emit = title
1363
+ try: self.viewTitleChanged.emit(self, title)
1364
+ except Exception as e:
1365
+ import logging
1366
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1367
+
1368
+
1369
+ def _strip_decorations(self, title: str) -> tuple[str, bool]:
1370
+ had = False
1371
+ # loop to remove multiple stacked badges, in any order
1372
+ while True:
1373
+ changed = False
1374
+
1375
+ # A) explicit multi-char prefixes
1376
+ for pref in DECORATION_PREFIXES:
1377
+ if title.startswith(pref):
1378
+ title = title[len(pref):]
1379
+ had = changed = True
1380
+
1381
+ # B) generic 1-glyph + space (covers any stray glyph in GLYPHS)
1382
+ if len(title) >= 2 and title[1] == " " and title[0] in GLYPHS:
1383
+ title = title[2:]
1384
+ had = changed = True
1385
+
1386
+ if not changed:
1387
+ break
1388
+
1389
+ return title, had
1390
+
1391
+
1392
+ def set_active_highlight(self, on: bool):
1393
+ self._is_active_flag = bool(on)
1394
+ return
1395
+ sub = self._mdi_subwindow()
1396
+ if not sub:
1397
+ return
1398
+
1399
+ core, had_glyph = self._strip_decorations(sub.windowTitle())
1400
+
1401
+ if on and not getattr(self, "_suppress_active_once", False):
1402
+ core = ACTIVE_PREFIX + core
1403
+ self._suppress_active_once = False
1404
+
1405
+ # recompose: glyph (from flag), then active prefix, then base/core
1406
+ if getattr(self, "_mask_dot_enabled", False):
1407
+ core = "■ " + core
1408
+ #sub.setWindowTitle(core)
1409
+ sub.setToolTip(core)
1410
+
1411
+ def _set_mask_highlight(self, on: bool):
1412
+ self._mask_dot_enabled = bool(on)
1413
+ self._rebuild_title()
1414
+
1415
+ def _sync_host_title(self):
1416
+ # document renamed → rebuild from flags + new base
1417
+ self._rebuild_title()
1418
+
1419
+
1420
+
1421
+ def base_doc_title(self) -> str:
1422
+ """The clean, base title (document display name), no prefixes/suffixes."""
1423
+ return self.document.display_name() or "Untitled"
1424
+
1425
+ def _active_mask_array(self):
1426
+ """Return the active mask ndarray (H,W) or None."""
1427
+ doc = getattr(self, "document", None)
1428
+ if not doc:
1429
+ return None
1430
+ mid = getattr(doc, "active_mask_id", None)
1431
+ if not mid:
1432
+ return None
1433
+ masks = getattr(doc, "masks", {}) or {}
1434
+ layer = masks.get(mid)
1435
+ if layer is None:
1436
+ return None
1437
+ data = getattr(layer, "data", None)
1438
+ if data is None:
1439
+ return None
1440
+ import numpy as np
1441
+ a = np.asarray(data)
1442
+ if a.ndim == 3 and a.shape[2] == 1:
1443
+ a = a[..., 0]
1444
+ if a.ndim != 2:
1445
+ return None
1446
+ # ensure 0..1 float
1447
+ a = a.astype(np.float32, copy=False)
1448
+ a = np.clip(a, 0.0, 1.0)
1449
+ return a
1450
+
1451
+ def refresh_mask_overlay(self):
1452
+ """Recompute the source buffer (incl. red mask tint) and repaint."""
1453
+ self._render(rebuild=True)
1454
+
1455
+ def _apply_subwindow_style(self):
1456
+ """No-op shim retained for backward compatibility."""
1457
+ pass
1458
+
1459
+ def _on_doc_mask_changed(self):
1460
+ """Doc changed → refresh highlight and overlay if needed."""
1461
+ has_mask = self._active_mask_array() is not None
1462
+ self._set_mask_highlight(has_mask)
1463
+ if self.show_mask_overlay and has_mask:
1464
+ self._render(rebuild=True)
1465
+ elif self.show_mask_overlay and not has_mask:
1466
+ # overlay was on but mask went away → just redraw to clear
1467
+ self._render(rebuild=True)
1468
+
1469
+
1470
+ # ---------- public API ----------
1471
+ def set_autostretch(self, on: bool):
1472
+ on = bool(on)
1473
+ if on == getattr(self, "autostretch_enabled", False):
1474
+ # still rebuild so linked profile changes can reflect immediately if desired
1475
+ pass
1476
+ self.autostretch_enabled = on
1477
+ try:
1478
+ self.autostretchChanged.emit(on)
1479
+ except Exception:
1480
+ pass
1481
+ # keep your newer fast-path behavior
1482
+ self._recompute_autostretch_and_update()
1483
+
1484
+ def toggle_autostretch(self):
1485
+ self.set_autostretch(not self.autostretch_enabled)
1486
+
1487
+ def set_autostretch_target(self, target: float):
1488
+ self.autostretch_target = float(target)
1489
+ if self.autostretch_enabled:
1490
+ self._render(rebuild=True)
1491
+
1492
+ def set_autostretch_sigma(self, sigma: float):
1493
+ self.autostretch_sigma = float(sigma)
1494
+ if self.autostretch_enabled:
1495
+ self._render(rebuild=True)
1496
+
1497
+ def set_autostretch_profile(self, profile: str):
1498
+ """'normal' => target=0.25, sigma=3 ; 'hard' => target=0.5, sigma=1"""
1499
+ p = (profile or "").lower()
1500
+ if p not in ("normal", "hard"):
1501
+ p = "normal"
1502
+ if p == self.autostretch_profile:
1503
+ return
1504
+ if p == "hard":
1505
+ self.autostretch_target = 0.5
1506
+ self.autostretch_sigma = 2
1507
+ else:
1508
+ self.autostretch_target = 0.3
1509
+ self.autostretch_sigma = 5
1510
+ self.autostretch_profile = p
1511
+ if self.autostretch_enabled:
1512
+ self._render(rebuild=True)
1513
+
1514
+ def is_hard_autostretch(self) -> bool:
1515
+ return self.autostretch_profile == "hard"
1516
+
1517
+ def _mdi_subwindow(self) -> QMdiSubWindow | None:
1518
+ w = self.parent()
1519
+ while w is not None and not isinstance(w, QMdiSubWindow):
1520
+ w = w.parent()
1521
+ return w
1522
+
1523
+ def _effective_title(self) -> str:
1524
+ # Prefer a per-view override; otherwise doc display name
1525
+ return self._view_title_override or self.document.display_name()
1526
+
1527
+ def _show_ctx_menu(self, pos):
1528
+ menu = QMenu(self)
1529
+ a_view = menu.addAction("Rename View… (F2)")
1530
+ a_doc = menu.addAction("Rename Document…")
1531
+ menu.addSeparator()
1532
+ a_min = menu.addAction("Send to Shelf")
1533
+ a_clear = menu.addAction("Clear View Name (use doc name)")
1534
+ menu.addSeparator()
1535
+ a_unlink = menu.addAction("Unlink from Linked Views") # ← NEW
1536
+ menu.addSeparator()
1537
+ a_help = menu.addAction("Show pixel/WCS readout hint")
1538
+ menu.addSeparator()
1539
+ a_prev = menu.addAction("Create Preview (drag rectangle)")
1540
+
1541
+ act = menu.exec(self.mapToGlobal(pos))
1542
+
1543
+ if act == a_view:
1544
+ self._rename_view()
1545
+ elif act == a_doc:
1546
+ self._rename_document()
1547
+ elif act == a_min:
1548
+ self._send_to_shelf()
1549
+ elif act == a_clear:
1550
+ self._view_title_override = None
1551
+ self._sync_host_title()
1552
+ elif act == a_unlink:
1553
+ self.unlink_all()
1554
+ elif act == a_help:
1555
+ self._announce_readout_help()
1556
+ elif act == a_prev:
1557
+ self._preview_btn.setChecked(True)
1558
+ self._toggle_preview_select_mode(True)
1559
+
1560
+
1561
+
1562
+ def _send_to_shelf(self):
1563
+ sub = self._mdi_subwindow()
1564
+ mw = self._find_main_window()
1565
+ if sub and mw and hasattr(mw, "window_shelf"):
1566
+ sub.hide()
1567
+ mw.window_shelf.add_entry(sub)
1568
+
1569
+
1570
+ def _rename_view(self):
1571
+ current = self._view_title_override or self.document.display_name()
1572
+ new, ok = QInputDialog.getText(self, "Rename View", "New view name:", text=current)
1573
+ if ok and new.strip():
1574
+ self._view_title_override = new.strip()
1575
+ self._sync_host_title() # calls _rebuild_title → emits viewTitleChanged
1576
+
1577
+ # optional: directly ping layers dock (defensive)
1578
+ mw = self._find_main_window()
1579
+ if mw and hasattr(mw, "layers_dock") and mw.layers_dock:
1580
+ try:
1581
+ mw.layers_dock._refresh_titles_only()
1582
+ except Exception:
1583
+ pass
1584
+
1585
+ def _rename_document(self):
1586
+ current = self.document.display_name()
1587
+ new, ok = QInputDialog.getText(self, "Rename Document", "New document name:", text=current)
1588
+ if ok and new.strip():
1589
+ # store on the doc so Explorer + other views update too
1590
+ self.document.metadata["display_name"] = new.strip()
1591
+ self.document.changed.emit() # triggers all listeners
1592
+ # If this view had an override equal to the old name, drop it
1593
+ if self._view_title_override and self._view_title_override == current:
1594
+ self._view_title_override = None
1595
+ self._sync_host_title()
1596
+ mw = self._find_main_window()
1597
+ if mw and hasattr(mw, "layers_dock") and mw.layers_dock:
1598
+ try:
1599
+ mw.layers_dock._refresh_titles_only()
1600
+ except Exception:
1601
+ pass
1602
+
1603
+ def set_scale(self, s: float):
1604
+ s = float(max(self._min_scale, min(s, self._max_scale)))
1605
+ if abs(s - self.scale) < 1e-9:
1606
+ return
1607
+ self.scale = s
1608
+ self._render() # only scale needs a redraw
1609
+ self._schedule_emit_view_transform()
1610
+
1611
+
1612
+
1613
+ # ---- view state API (center in image coords + scale) ----
1614
+ #def get_view_state(self) -> dict:
1615
+ # pm = self.label.pixmap()
1616
+ # if pm is None:
1617
+ # return {"scale": self.scale, "center": (0.0, 0.0)}
1618
+ # vp = self.scroll.viewport().size()
1619
+ # hbar = self.scroll.horizontalScrollBar()
1620
+ # vbar = self.scroll.verticalScrollBar()
1621
+ # cx_label = hbar.value() + vp.width() / 2.0
1622
+ # cy_label = vbar.value() + vp.height() / 2.0
1623
+ # return {
1624
+ # "scale": float(self.scale),
1625
+ # "center": (float(cx_label / max(1e-6, self.scale)),
1626
+ # float(cy_label / max(1e-6, self.scale)))
1627
+ # }
1628
+
1629
+ def _start_viewstate_drag(self):
1630
+ """Package view state + robust doc identity into a drag."""
1631
+ hbar = self.scroll.horizontalScrollBar()
1632
+ vbar = self.scroll.verticalScrollBar()
1633
+
1634
+ state = {
1635
+ "doc_ptr": id(self.document), # legacy
1636
+ "scale": float(self.scale),
1637
+ "hval": int(hbar.value()),
1638
+ "vval": int(vbar.value()),
1639
+ "autostretch": bool(self.autostretch_enabled),
1640
+ "autostretch_target": float(self.autostretch_target),
1641
+ }
1642
+ state.update(self._drag_identity_fields()) # uid + base_uid + file_path
1643
+
1644
+ # --- NEW: annotate ROI/source_kind so drop knows this came from a Preview tab
1645
+ roi = None
1646
+ try:
1647
+ if hasattr(self, "has_active_preview") and self.has_active_preview():
1648
+ r = self.current_preview_roi() # (x,y,w,h) in full-image coords
1649
+ if r and len(r) == 4:
1650
+ roi = tuple(map(int, r))
1651
+ except Exception:
1652
+ roi = None
1653
+
1654
+ if roi:
1655
+ state["roi"] = roi
1656
+ state["source_kind"] = "roi-preview"
1657
+ try:
1658
+ pname = self.current_preview_name()
1659
+ except Exception:
1660
+ pname = None
1661
+ if pname:
1662
+ state["preview_name"] = str(pname)
1663
+ else:
1664
+ state["source_kind"] = "full"
1665
+
1666
+ md = QMimeData()
1667
+ md.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
1668
+
1669
+ drag = QDrag(self)
1670
+ drag.setMimeData(md)
1671
+ if self.label.pixmap():
1672
+ drag.setPixmap(self.label.pixmap())
1673
+ drag.exec()
1674
+
1675
+
1676
+
1677
+ def _start_mask_drag(self):
1678
+ """
1679
+ Start a drag that carries 'this document is a mask' to drop targets.
1680
+ """
1681
+ doc = self.document
1682
+ if doc is None:
1683
+ return
1684
+
1685
+ payload = {
1686
+ # New-style field
1687
+ "mask_doc_ptr": id(doc),
1688
+
1689
+ # Backward-compat field: many handlers still look for 'doc_ptr'
1690
+ "doc_ptr": id(doc),
1691
+
1692
+ "mode": "replace", # future: "union"/"intersect"/"diff"
1693
+ "invert": False,
1694
+ "feather": 0.0, # px
1695
+ "name": doc.display_name(),
1696
+ }
1697
+
1698
+ # Add identity hints (uids, base uid, file_path)
1699
+ payload.update(self._drag_identity_fields())
1700
+
1701
+ md = QMimeData()
1702
+ md.setData(MIME_MASK, QByteArray(json.dumps(payload).encode("utf-8")))
1703
+
1704
+ drag = QDrag(self)
1705
+ drag.setMimeData(md)
1706
+ if self.label.pixmap():
1707
+ drag.setPixmap(
1708
+ self.label.pixmap().scaled(
1709
+ 64, 64,
1710
+ Qt.AspectRatioMode.KeepAspectRatio,
1711
+ Qt.TransformationMode.SmoothTransformation,
1712
+ )
1713
+ )
1714
+ drag.setHotSpot(QPoint(16, 16))
1715
+ drag.exec(Qt.DropAction.CopyAction)
1716
+
1717
+ def _start_astrometry_drag(self):
1718
+ """
1719
+ Start a drag that carries 'copy astrometric solution from this document'.
1720
+ We only send a pointer; the main window resolves + copies actual WCS.
1721
+ """
1722
+ payload = {
1723
+ "wcs_from_doc_ptr": id(self.document),
1724
+ "name": self.document.display_name(),
1725
+ }
1726
+ payload.update(self._drag_identity_fields())
1727
+ md = QMimeData()
1728
+ md.setData(MIME_ASTROMETRY, QByteArray(json.dumps(payload).encode("utf-8")))
1729
+
1730
+ drag = QDrag(self)
1731
+ drag.setMimeData(md)
1732
+ if self.label.pixmap():
1733
+ drag.setPixmap(self.label.pixmap().scaled(
1734
+ 64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
1735
+ drag.setHotSpot(QPoint(16, 16))
1736
+ drag.exec(Qt.DropAction.CopyAction)
1737
+
1738
+
1739
+ def apply_view_state(self, st: dict):
1740
+ try:
1741
+ new_scale = float(st.get("scale", self.scale))
1742
+ except Exception:
1743
+ new_scale = self.scale
1744
+ # clamp with new max
1745
+ self.scale = max(self._min_scale, min(new_scale, self._max_scale))
1746
+ self._render(rebuild=False)
1747
+
1748
+ vp = self.scroll.viewport().size()
1749
+ hbar = self.scroll.horizontalScrollBar()
1750
+ vbar = self.scroll.verticalScrollBar()
1751
+
1752
+ if "hval" in st or "vval" in st:
1753
+ # direct scrollbar values (fast path)
1754
+ hv = int(st.get("hval", hbar.value()))
1755
+ vv = int(st.get("vval", vbar.value()))
1756
+ hbar.setValue(hv)
1757
+ vbar.setValue(vv)
1758
+ return
1759
+
1760
+ # fallback: center in image coordinates
1761
+ center = st.get("center")
1762
+ if center is None:
1763
+ return
1764
+ try:
1765
+ cx_img, cy_img = float(center[0]), float(center[1])
1766
+ except Exception:
1767
+ return
1768
+ cx_label = cx_img * self.scale
1769
+ cy_label = cy_img * self.scale
1770
+ hbar.setValue(int(cx_label - vp.width() / 2.0))
1771
+ vbar.setValue(int(cy_label - vp.height() / 2.0))
1772
+ self._emit_view_transform()
1773
+
1774
+
1775
+ # ---- DnD 'view tab' -------------------------------------------------
1776
+ def _install_view_tab(self):
1777
+ self._view_tab = QToolButton(self)
1778
+ self._view_tab.setText("View")
1779
+ self._view_tab.setToolTip("Drag onto another window to copy zoom/pan.\n"
1780
+ "Double-click to duplicate this view.")
1781
+ self._view_tab.setCursor(Qt.CursorShape.OpenHandCursor)
1782
+ self._view_tab.setAutoRaise(True)
1783
+ self._view_tab.move(8, 8) # pinned near top-left of the subwindow
1784
+ self._view_tab.show()
1785
+
1786
+ # start drag on press
1787
+ self._view_tab.mousePressEvent = self._viewtab_mouse_press
1788
+ # duplicate on double-click
1789
+ self._view_tab.mouseDoubleClickEvent = self._viewtab_mouse_double
1790
+
1791
+ def _viewtab_mouse_press(self, ev):
1792
+ if ev.button() != Qt.MouseButton.LeftButton:
1793
+ return QToolButton.mousePressEvent(self._view_tab, ev)
1794
+
1795
+ # build the SAME payload schema used by _start_viewstate_drag()
1796
+ hbar = self.scroll.horizontalScrollBar()
1797
+ vbar = self.scroll.verticalScrollBar()
1798
+ state = {
1799
+ "doc_ptr": id(self.document),
1800
+ "scale": float(self.scale),
1801
+ "hval": int(hbar.value()),
1802
+ "vval": int(vbar.value()),
1803
+ "autostretch": bool(self.autostretch_enabled),
1804
+ "autostretch_target": float(self.autostretch_target),
1805
+ }
1806
+ state.update(self._drag_identity_fields())
1807
+
1808
+ mime = QMimeData()
1809
+ mime.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
1810
+
1811
+ drag = QDrag(self)
1812
+ drag.setMimeData(mime)
1813
+
1814
+ pm = self.label.pixmap()
1815
+ if pm:
1816
+ drag.setPixmap(pm.scaled(96, 96,
1817
+ Qt.AspectRatioMode.KeepAspectRatio,
1818
+ Qt.TransformationMode.SmoothTransformation))
1819
+ drag.setHotSpot(QCursor.pos() - self.mapToGlobal(self._view_tab.pos()))
1820
+ drag.exec(Qt.DropAction.CopyAction)
1821
+
1822
+ def _viewtab_mouse_double(self, _ev):
1823
+ # ask main window to duplicate this subwindow
1824
+ self.requestDuplicate.emit(self)
1825
+
1826
+ # accept view-state drops anywhere in the view
1827
+ def dragEnterEvent(self, ev):
1828
+ md = ev.mimeData()
1829
+
1830
+ if (md.hasFormat(MIME_VIEWSTATE)
1831
+ or md.hasFormat(MIME_ASTROMETRY)
1832
+ or md.hasFormat(MIME_MASK)
1833
+ or md.hasFormat(MIME_CMD)
1834
+ or md.hasFormat(MIME_LINKVIEW)):
1835
+ ev.acceptProposedAction()
1836
+ else:
1837
+ ev.ignore()
1838
+
1839
+ def dragMoveEvent(self, ev):
1840
+ md = ev.mimeData()
1841
+
1842
+ if (md.hasFormat(MIME_VIEWSTATE)
1843
+ or md.hasFormat(MIME_ASTROMETRY)
1844
+ or md.hasFormat(MIME_MASK)
1845
+ or md.hasFormat(MIME_CMD)
1846
+ or md.hasFormat(MIME_LINKVIEW)):
1847
+ ev.acceptProposedAction()
1848
+ else:
1849
+ ev.ignore()
1850
+
1851
+ def dropEvent(self, ev):
1852
+ md = ev.mimeData()
1853
+
1854
+ # 0) Function/Action command → forward to main window for headless/UI routing
1855
+ if md.hasFormat(MIME_CMD):
1856
+ try:
1857
+ payload = _unpack_cmd_payload(bytes(md.data(MIME_CMD)))
1858
+ except Exception:
1859
+ ev.ignore(); return
1860
+ mw = self._find_main_window()
1861
+ sw = self._mdi_subwindow()
1862
+ if mw and sw and hasattr(mw, "_handle_command_drop"):
1863
+ mw._handle_command_drop(payload, sw)
1864
+ ev.acceptProposedAction()
1865
+ else:
1866
+ ev.ignore()
1867
+ return
1868
+
1869
+ # 1) view state (existing)
1870
+ if md.hasFormat(MIME_VIEWSTATE):
1871
+ try:
1872
+ st = json.loads(bytes(md.data(MIME_VIEWSTATE)).decode("utf-8"))
1873
+ self.apply_view_state(st)
1874
+ ev.acceptProposedAction()
1875
+ except Exception:
1876
+ ev.ignore()
1877
+ return
1878
+
1879
+ # 2) mask (NEW) → forward to main-window handler using this view as target
1880
+ if md.hasFormat(MIME_MASK):
1881
+ try:
1882
+ payload = json.loads(bytes(md.data(MIME_MASK)).decode("utf-8"))
1883
+ except Exception:
1884
+ ev.ignore(); return
1885
+ mw = self._find_main_window()
1886
+ sw = self._mdi_subwindow()
1887
+ if mw and sw and hasattr(mw, "_handle_mask_drop"):
1888
+ mw._handle_mask_drop(payload, sw)
1889
+ ev.acceptProposedAction()
1890
+ else:
1891
+ ev.ignore()
1892
+ return
1893
+
1894
+ # 3) astrometry (existing forwarding)
1895
+ if md.hasFormat(MIME_ASTROMETRY):
1896
+ try:
1897
+ payload = json.loads(bytes(md.data(MIME_ASTROMETRY)).decode("utf-8"))
1898
+ except Exception:
1899
+ ev.ignore(); return
1900
+ mw = self._find_main_window()
1901
+ sw = self._mdi_subwindow()
1902
+ if mw and hasattr(mw, "_on_astrometry_drop") and sw is not None:
1903
+ mw._on_astrometry_drop(payload, sw)
1904
+ ev.acceptProposedAction()
1905
+ else:
1906
+ ev.ignore()
1907
+ return
1908
+
1909
+ if md.hasFormat(MIME_LINKVIEW):
1910
+ try:
1911
+ payload = json.loads(bytes(md.data(MIME_LINKVIEW)).decode("utf-8"))
1912
+ sid = int(payload.get("source_view_id"))
1913
+ except Exception:
1914
+ ev.ignore(); return
1915
+ src = ImageSubWindow._registry.get(sid)
1916
+ if src is not None and src is not self:
1917
+ src.link_to(self)
1918
+ ev.acceptProposedAction()
1919
+ else:
1920
+ ev.ignore()
1921
+ return
1922
+
1923
+ ev.ignore()
1924
+
1925
+ # keep the tab visible if the widget resizes
1926
+ def resizeEvent(self, ev):
1927
+ super().resizeEvent(ev)
1928
+ try:
1929
+ self.resized.emit()
1930
+ except Exception:
1931
+ pass
1932
+ if hasattr(self, "_view_tab"):
1933
+ self._view_tab.raise_()
1934
+
1935
+ def is_autostretch_linked(self) -> bool:
1936
+ return bool(self._autostretch_linked)
1937
+
1938
+ def set_autostretch_linked(self, linked: bool):
1939
+ linked = bool(linked)
1940
+ if self._autostretch_linked == linked:
1941
+ return
1942
+ self._autostretch_linked = linked
1943
+ if self.autostretch_enabled:
1944
+ self._recompute_autostretch_and_update()
1945
+
1946
+ def _on_docman_nudge(self, *args):
1947
+ # Guard against late signals hitting after destruction/minimize
1948
+ try:
1949
+ from PyQt6 import sip as _sip
1950
+ if _sip.isdeleted(self):
1951
+ return
1952
+ except Exception:
1953
+ pass
1954
+ try:
1955
+ self._refresh_local_undo_buttons()
1956
+ except RuntimeError:
1957
+ # Buttons already gone; safe to ignore
1958
+ pass
1959
+ except Exception:
1960
+ pass
1961
+
1962
+
1963
+ def _recompute_autostretch_and_update(self):
1964
+ self._qimg_src = None # force source rebuild
1965
+ self._render(True)
1966
+
1967
+ def set_doc_manager(self, docman):
1968
+ self._docman = docman
1969
+ try:
1970
+ docman.imageRegionUpdated.connect(self._on_doc_region_updated)
1971
+ docman.imageRegionUpdated.connect(self._on_docman_nudge)
1972
+ if hasattr(docman, "previewRepaintRequested"):
1973
+ docman.previewRepaintRequested.connect(self._on_docman_nudge)
1974
+ except Exception:
1975
+ pass
1976
+
1977
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
1978
+ if base is not None:
1979
+ try:
1980
+ base.changed.connect(self._on_base_doc_changed)
1981
+ except Exception:
1982
+ pass
1983
+ self._install_history_watchers()
1984
+
1985
+ def _on_base_doc_changed(self):
1986
+ # Full-image changes (or unknown) → rebuild our pixmap
1987
+ QTimer.singleShot(0, lambda: (self._render(rebuild=True), self._refresh_local_undo_buttons()))
1988
+
1989
+ def _on_history_doc_changed(self):
1990
+ """
1991
+ Called when the current history document (full or ROI) changes.
1992
+ Ensures the pixmap is rebuilt immediately, including when a
1993
+ tool operates on a Preview/ROI doc.
1994
+ """
1995
+ QTimer.singleShot(0, lambda: (self._render(rebuild=True),
1996
+ self._refresh_local_undo_buttons()))
1997
+
1998
+ def _on_doc_region_updated(self, doc, roi_tuple_or_none):
1999
+ # Only react if it’s our base doc
2000
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
2001
+ if doc is None or base is None or doc is not base:
2002
+ return
2003
+
2004
+ # If not on a Preview tab, just refresh.
2005
+ if not (getattr(self, "_active_source_kind", None) == "preview"
2006
+ and getattr(self, "_active_preview_id", None) is not None):
2007
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2008
+ return
2009
+
2010
+ # We’re on a Preview tab: refresh only if the changed region overlaps our ROI.
2011
+ try:
2012
+ my_roi = self.current_preview_roi() # (x,y,w,h) in full-image coords
2013
+ except Exception:
2014
+ my_roi = None
2015
+
2016
+ if my_roi is None or roi_tuple_or_none is None:
2017
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2018
+ return
2019
+
2020
+ if self._roi_intersects(my_roi, roi_tuple_or_none):
2021
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2022
+
2023
+ @staticmethod
2024
+ def _roi_intersects(a, b):
2025
+ ax, ay, aw, ah = map(int, a)
2026
+ bx, by, bw, bh = map(int, b)
2027
+ if aw <= 0 or ah <= 0 or bw <= 0 or bh <= 0:
2028
+ return False
2029
+ return not (ax+aw <= bx or bx+bw <= ax or ay+ah <= by or by+bh <= ay)
2030
+
2031
+ def refresh_from_docman(self):
2032
+ #print("[ImageSubWindow] refresh_from_docman called")
2033
+ """
2034
+ Called by MainWindow when DocManager says the image changed.
2035
+ We nuke the cached QImage and rebuild from the current doc proxy
2036
+ (which resolves ROI vs full), so the Preview tab repaints correctly.
2037
+ """
2038
+ try:
2039
+ # Invalidate any cached source so _render() fully rebuilds
2040
+ if hasattr(self, "_qimg_src"):
2041
+ self._qimg_src = None
2042
+ except Exception:
2043
+ pass
2044
+ self._render(rebuild=True)
2045
+
2046
+ def _deg_to_hms(self, ra_deg: float) -> str:
2047
+ """RA in degrees → 'HH:MM:SS' (rounded secs, with carry)."""
2048
+ ra_h = ra_deg / 15.0
2049
+ hh = int(ra_h) % 24
2050
+ mmf = (ra_h - hh) * 60.0
2051
+ mm = int(mmf)
2052
+ ss = int(round((mmf - mm) * 60.0))
2053
+ if ss == 60:
2054
+ ss = 0; mm += 1
2055
+ if mm == 60:
2056
+ mm = 0; hh = (hh + 1) % 24
2057
+ return f"{hh:02d}:{mm:02d}:{ss:02d}"
2058
+
2059
+ def _deg_to_dms(self, dec_deg: float) -> str:
2060
+ """Dec in degrees → '±DD:MM:SS' (rounded secs, with carry)."""
2061
+ sign = "+" if dec_deg >= 0 else "-"
2062
+ d = abs(dec_deg)
2063
+ dd = int(d)
2064
+ mf = (d - dd) * 60.0
2065
+ mm = int(mf)
2066
+ ss = int(round((mf - mm) * 60.0))
2067
+ if ss == 60:
2068
+ ss = 0; mm += 1
2069
+ if mm == 60:
2070
+ mm = 0; dd += 1
2071
+ return f"{sign}{dd:02d}:{mm:02d}:{ss:02d}"
2072
+
2073
+
2074
+ # ---------- rendering ----------
2075
+ def _render(self, rebuild: bool = False):
2076
+ """
2077
+ Render the current view.
2078
+
2079
+ Rules:
2080
+ - If a Preview is active, FIRST sync that preview's stored arr from the
2081
+ DocManager's ROI document (the thing tools actually modify), then render.
2082
+ - Never reslice from the parent/full image here.
2083
+ - Keep a strong reference to the numpy buffer that backs the QImage.
2084
+ """
2085
+ # ---- GUARD: widget/label may be deleted but document.changed still fires ----
2086
+ try:
2087
+ from PyQt6 import sip as _sip
2088
+ # If the whole widget or its label is gone, bail immediately
2089
+ if _sip.isdeleted(self):
2090
+ return
2091
+ lbl = getattr(self, "label", None)
2092
+ if lbl is None or _sip.isdeleted(lbl):
2093
+ return
2094
+ except Exception:
2095
+ # If sip or label is missing for any reason, play it safe
2096
+ if not hasattr(self, "label"):
2097
+ return
2098
+ # ---------------------------------------------------------------------------
2099
+ # ---------------------------
2100
+ # 1) Choose & sync source arr
2101
+ # ---------------------------
2102
+ base_img = None
2103
+ if self._active_source_kind == "preview" and self._active_preview_id is not None:
2104
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2105
+ #print("[ImageSubWindow] _render: preview mode, id =", self._active_preview_id, "src =", src is not None)
2106
+ if src is not None:
2107
+ # Pull the *edited* ROI image from DocManager, if available
2108
+ if hasattr(self, "_docman") and self._docman is not None:
2109
+ #print("[ImageSubWindow] _render: pulling edited ROI from DocManager")
2110
+ try:
2111
+ roi_doc = self._docman.get_document_for_view(self)
2112
+ roi_img = getattr(roi_doc, "image", None)
2113
+ if roi_img is not None:
2114
+ # Replace the preview’s static copy with the edited ROI buffer
2115
+ src["arr"] = np.asarray(roi_img).copy()
2116
+ except Exception:
2117
+ print("[ImageSubWindow] _render: failed to pull edited ROI from DocManager")
2118
+ pass
2119
+ base_img = src.get("arr", None)
2120
+ else:
2121
+ #print("[ImageSubWindow] _render: full image mode")
2122
+ base_img = self._display_override if (self._display_override is not None) else (
2123
+ getattr(self.document, "image", None)
2124
+ )
2125
+
2126
+ if base_img is None:
2127
+ self._qimg_src = None
2128
+ self.label.clear()
2129
+ return
2130
+
2131
+ arr = np.asarray(base_img)
2132
+
2133
+ # ---------------------------------------
2134
+ # 2) Normalize dimensionality and dtype
2135
+ # ---------------------------------------
2136
+ # Scalar → 1x1; 1D → 1xN; (H,W,1) → mono (H,W)
2137
+ if arr.ndim == 0:
2138
+ arr = arr.reshape(1, 1)
2139
+ elif arr.ndim == 1:
2140
+ arr = arr[np.newaxis, :]
2141
+ elif arr.ndim == 3 and arr.shape[2] == 1:
2142
+ arr = arr[..., 0]
2143
+
2144
+ is_mono = (arr.ndim == 2)
2145
+
2146
+ # ---------------------------------------
2147
+ # 3) Visualization buffer (float32)
2148
+ # ---------------------------------------
2149
+ if self.autostretch_enabled:
2150
+ if np.issubdtype(arr.dtype, np.integer):
2151
+ info = np.iinfo(arr.dtype)
2152
+ denom = float(max(1, info.max))
2153
+ arr_f = (arr.astype(np.float32) / denom)
2154
+ else:
2155
+ arr_f = arr.astype(np.float32, copy=False)
2156
+ mx = float(arr_f.max()) if arr_f.size else 1.0
2157
+ if mx > 5.0: # compress absurdly large ranges
2158
+ arr_f = arr_f / mx
2159
+
2160
+ vis = autostretch(
2161
+ arr_f,
2162
+ target_median=self.autostretch_target,
2163
+ sigma=self.autostretch_sigma,
2164
+ linked=(not is_mono and self._autostretch_linked),
2165
+ use_16bit=None,
2166
+ )
2167
+ else:
2168
+ vis = arr
2169
+
2170
+ # ---------------------------------------
2171
+ # 4) Convert to 8-bit RGB for QImage
2172
+ # ---------------------------------------
2173
+ if vis.dtype == np.uint8:
2174
+ buf8 = vis
2175
+ elif vis.dtype == np.uint16:
2176
+ buf8 = (vis.astype(np.float32) / 65535.0 * 255.0).clip(0, 255).astype(np.uint8)
2177
+ else:
2178
+ buf8 = (np.clip(vis.astype(np.float32, copy=False), 0.0, 1.0) * 255.0).astype(np.uint8)
2179
+
2180
+ # Force H×W×3
2181
+ if buf8.ndim == 2:
2182
+ buf8 = np.stack([buf8] * 3, axis=-1)
2183
+ elif buf8.ndim == 3:
2184
+ c = buf8.shape[2]
2185
+ if c == 1:
2186
+ buf8 = np.repeat(buf8, 3, axis=2)
2187
+ elif c > 3:
2188
+ buf8 = buf8[..., :3]
2189
+ else:
2190
+ buf8 = np.stack([buf8.squeeze()] * 3, axis=-1)
2191
+
2192
+ # ---------------------------------------
2193
+ # 5) Optional mask overlay
2194
+ # ---------------------------------------
2195
+ if getattr(self, "show_mask_overlay", False):
2196
+ m = self._active_mask_array()
2197
+ if m is not None:
2198
+ if getattr(self, "_mask_overlay_invert", True):
2199
+ m = 1.0 - m
2200
+ th, tw = buf8.shape[:2]
2201
+ sh, sw = m.shape[:2]
2202
+ if (sh, sw) != (th, tw):
2203
+ yi = (np.linspace(0, sh - 1, th)).astype(np.int32)
2204
+ xi = (np.linspace(0, sw - 1, tw)).astype(np.int32)
2205
+ m = m[yi][:, xi]
2206
+ a = m.astype(np.float32, copy=False) * float(getattr(self, "_mask_overlay_alpha", 0.35))
2207
+ bf = buf8.astype(np.float32, copy=False)
2208
+ bf[..., 0] = np.clip(bf[..., 0] + (255.0 - bf[..., 0]) * a, 0.0, 255.0)
2209
+ buf8 = bf.astype(np.uint8, copy=False)
2210
+
2211
+ # ---------------------------------------
2212
+ # 6) Wrap into QImage (keep buffer alive)
2213
+ # ---------------------------------------
2214
+ if buf8.dtype != np.uint8:
2215
+ buf8 = buf8.astype(np.uint8)
2216
+ buf8 = ensure_contiguous(buf8)
2217
+ h, w, c = buf8.shape
2218
+ # Be explicit. RGB888 means 3 bytes per pixel, full stop.
2219
+ bytes_per_line = int(w * 3)
2220
+
2221
+ self._buf8 = buf8 # keep alive
2222
+
2223
+ try:
2224
+ addr = int(self._buf8.ctypes.data)
2225
+ ptr = sip.voidptr(addr)
2226
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
2227
+ # Defensive: if Qt ever decides the buffer looks wrong, force-copy once
2228
+ if qimg is None or qimg.isNull():
2229
+ raise RuntimeError("QImage null")
2230
+ except Exception:
2231
+ # One safe fall-back copy (still fast, avoids crashes)
2232
+ buf8c = np.array(self._buf8, copy=True, order="C")
2233
+ self._buf8 = buf8c
2234
+ addr = int(self._buf8.ctypes.data)
2235
+ ptr = sip.voidptr(addr)
2236
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
2237
+
2238
+ self._qimg_src = qimg
2239
+ if qimg is None or qimg.isNull():
2240
+ self.label.clear()
2241
+ return
2242
+
2243
+ # ---------------------------------------
2244
+ # 7) Scale & present
2245
+ # ---------------------------------------
2246
+ sw = max(1, int(qimg.width() * self.scale))
2247
+ sh = max(1, int(qimg.height() * self.scale))
2248
+ scaled = qimg.scaled(
2249
+ sw, sh,
2250
+ Qt.AspectRatioMode.KeepAspectRatio,
2251
+ Qt.TransformationMode.SmoothTransformation
2252
+ )
2253
+
2254
+ # ── NEW: WCS grid overlay (draw on the scaled pixmap so lines stay 1px) ──
2255
+ if getattr(self, "_show_wcs_grid", False):
2256
+ wcs2 = self._get_celestial_wcs()
2257
+ if wcs2 is not None:
2258
+ from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
2259
+ from PyQt6.QtCore import QSettings
2260
+ from astropy.wcs.utils import proj_plane_pixel_scales
2261
+ import numpy as _np
2262
+
2263
+ pm = QPixmap.fromImage(scaled)
2264
+
2265
+ # Read user prefs (fallback to defaults if not set)
2266
+ _settings = getattr(self, "_settings", None) or QSettings()
2267
+ pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
2268
+ pref_mode = _settings.value("wcs_grid/mode", "auto", type=str) # "auto" | "fixed"
2269
+ pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str) # "deg" | "arcmin"
2270
+ pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
2271
+
2272
+ if not pref_enabled:
2273
+ # User disabled the grid in Preferences — skip overlay
2274
+ self.label.setPixmap(QPixmap.fromImage(scaled))
2275
+ self.label.resize(scaled.size())
2276
+ return
2277
+
2278
+ display_h, display_w = base_img.shape[:2]
2279
+
2280
+ # Pixel scales and FOV using celestial WCS
2281
+ px_scales_deg = proj_plane_pixel_scales(wcs2) # deg/pix for the two celestial axes
2282
+ px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
2283
+
2284
+ H_full, W_full = display_h, display_w
2285
+ fov_deg = px_deg * float(max(W_full, H_full))
2286
+
2287
+ # Choose grid spacing from prefs (or auto heuristic)
2288
+ if pref_mode == "fixed":
2289
+ step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
2290
+ step_deg = max(1e-6, min(step_deg, 90.0)) # clamp to sane range
2291
+ else:
2292
+ # Auto spacing (your previous logic)
2293
+ nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
2294
+ target_lines = 8
2295
+ desired = max(fov_deg / target_lines, px_deg * 100)
2296
+ step_deg = min((n for n in nice if n >= desired), default=30)
2297
+
2298
+ # World rect from image corners using celestial WCS
2299
+ corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
2300
+ try:
2301
+ ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
2302
+ ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
2303
+ dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
2304
+ if ra_max - ra_min > 300:
2305
+ ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
2306
+ ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
2307
+ ra_shift = 180.0
2308
+ else:
2309
+ ra_shift = 0.0
2310
+ except Exception:
2311
+ ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
2312
+
2313
+ p = QPainter(pm)
2314
+ pen = QPen(); pen.setWidth(1); pen.setColor(QColor(255, 255, 255, 140))
2315
+ p.setPen(pen)
2316
+ s = float(self.scale)
2317
+ img_w = int(W_full * s)
2318
+ img_h = int(H_full * s)
2319
+ Wf, Hf = float(W_full), float(H_full)
2320
+ margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
2321
+ def draw_world_poly(xs_world, ys_world):
2322
+ try:
2323
+ px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
2324
+ except Exception:
2325
+ return
2326
+
2327
+ px = _np.asarray(px, dtype=float)
2328
+ py = _np.asarray(py, dtype=float)
2329
+
2330
+ # --- validity mask ---
2331
+ ok = _np.isfinite(px) & _np.isfinite(py)
2332
+
2333
+ # Allow a margin around the image so near-edge lines still draw
2334
+ margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
2335
+ ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
2336
+ ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
2337
+
2338
+ for i in range(1, len(px)):
2339
+ if not (ok[i-1] and ok[i]):
2340
+ continue
2341
+
2342
+ x0 = float(px[i-1]) * s
2343
+ y0 = float(py[i-1]) * s
2344
+ x1 = float(px[i]) * s
2345
+ y1 = float(py[i]) * s
2346
+
2347
+ # Final sanity gate before int() -> Qt 32-bit
2348
+ if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
2349
+ continue
2350
+
2351
+ p.drawLine(int(x0), int(y0), int(x1), int(y1))
2352
+
2353
+
2354
+ ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
2355
+ ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
2356
+ dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
2357
+
2358
+ # DEC lines (horiz-ish)
2359
+ def _frange(a,b,s):
2360
+ out=[]; x=a
2361
+ while x <= b + 1e-9:
2362
+ out.append(x); x += s
2363
+ return out
2364
+ def _round_to(x,s): return s * round(x/s)
2365
+
2366
+ ra_start = _round_to(ra_min, step_deg)
2367
+ dec_start = _round_to(dec_min, step_deg)
2368
+ for dec in _frange(dec_start, dec_max, step_deg):
2369
+ dec_arr = _np.full_like(ra_samples_wrapped, dec)
2370
+ draw_world_poly(ra_samples_wrapped, dec_arr)
2371
+
2372
+ # RA lines (vert-ish)
2373
+ for ra in _frange(ra_start, ra_max, step_deg):
2374
+ ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
2375
+ draw_world_poly(ra_arr, dec_samples)
2376
+
2377
+ # ── LABELS for RA/Dec lines ─────────────────────────────────
2378
+ # Font & box style
2379
+ font = QFont(); font.setPixelSize(11) # screen-consistent
2380
+ p.setFont(font)
2381
+ text_pen = QPen(QColor(255, 255, 255, 230))
2382
+ box_brush = QBrush(QColor(0, 0, 0, 140))
2383
+ p.setPen(text_pen)
2384
+
2385
+ def _draw_label(x, y, txt, anchor="lt"):
2386
+ if not _np.isfinite([x, y]).all():
2387
+ return
2388
+ fm = p.fontMetrics()
2389
+ wtxt = fm.horizontalAdvance(txt) + 6
2390
+ htxt = fm.height() + 4
2391
+
2392
+ # initial placement with a little padding
2393
+ if anchor == "lt": # left-top
2394
+ rx, ry = int(x) + 4, int(y) + 3
2395
+ elif anchor == "rt": # right-top
2396
+ rx, ry = int(x) - wtxt - 4, int(y) + 3
2397
+ elif anchor == "lb": # left-bottom
2398
+ rx, ry = int(x) + 4, int(y) - htxt - 3
2399
+ else: # center-top
2400
+ rx, ry = int(x) - wtxt // 2, int(y) + 3
2401
+
2402
+ # clamp entirely inside the image
2403
+ rx = max(0, min(rx, img_w - wtxt - 1))
2404
+ ry = max(0, min(ry, img_h - htxt - 1))
2405
+
2406
+ rect = QRect(rx, ry, wtxt, htxt)
2407
+ p.save()
2408
+ p.setBrush(box_brush)
2409
+ p.setPen(Qt.PenStyle.NoPen)
2410
+ p.drawRoundedRect(rect, 4, 4)
2411
+ p.restore()
2412
+ p.drawText(rect.adjusted(3, 2, -3, -2),
2413
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
2414
+
2415
+
2416
+ # DEC labels on left edge
2417
+ for dec in _frange(dec_start, dec_max, step_deg):
2418
+ try:
2419
+ x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
2420
+ if not _np.isfinite([x_pix, y_pix]).all():
2421
+ continue
2422
+ # clamp to image bounds before scaling
2423
+ x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2424
+ y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2425
+ _draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
2426
+ except Exception:
2427
+ pass
2428
+
2429
+ # RA labels on top edge
2430
+ for ra in _frange(ra_start, ra_max, step_deg):
2431
+ ra_wrapped = (ra + ra_shift) % 360.0
2432
+ try:
2433
+ x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
2434
+ if not _np.isfinite([x_pix, y_pix]).all():
2435
+ continue
2436
+ x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2437
+ y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2438
+ _draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
2439
+ except Exception:
2440
+ pass
2441
+
2442
+ p.end()
2443
+ scaled = pm.toImage()
2444
+
2445
+ # ── end WCS grid overlay ────────────────────────────────────────────────
2446
+
2447
+ self.label.setPixmap(QPixmap.fromImage(scaled))
2448
+ self.label.resize(scaled.size())
2449
+
2450
+
2451
+
2452
+ def has_active_preview(self) -> bool:
2453
+ return self._active_source_kind == "preview" and self._active_preview_id is not None
2454
+
2455
+ def current_preview_roi(self) -> tuple[int,int,int,int] | None:
2456
+ """
2457
+ Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
2458
+ """
2459
+ if not self.has_active_preview():
2460
+ return None
2461
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2462
+ return None if src is None else tuple(src["roi"])
2463
+
2464
+ def current_preview_name(self) -> str | None:
2465
+ if not self.has_active_preview():
2466
+ return None
2467
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2468
+ return None if src is None else src["name"]
2469
+
2470
+
2471
+ # ---------- interaction ----------
2472
+ def _zoom_at_anchor(self, factor: float):
2473
+ if self._qimg_src is None:
2474
+ return
2475
+ old_scale = self.scale
2476
+ # clamp with new max
2477
+ new_scale = max(self._min_scale, min(old_scale * factor, self._max_scale))
2478
+ if abs(new_scale - old_scale) < 1e-8:
2479
+ return
2480
+
2481
+ vp = self.scroll.viewport()
2482
+ hbar = self.scroll.horizontalScrollBar()
2483
+ vbar = self.scroll.verticalScrollBar()
2484
+
2485
+ # Anchor in viewport coordinates via global cursor (robust)
2486
+ try:
2487
+ anchor_vp = vp.mapFromGlobal(QCursor.pos())
2488
+ except Exception:
2489
+ anchor_vp = None
2490
+
2491
+ if (anchor_vp is None) or (not vp.rect().contains(anchor_vp)):
2492
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
2493
+
2494
+ # Current label coords under the anchor
2495
+ x_label_pre = hbar.value() + anchor_vp.x()
2496
+ y_label_pre = vbar.value() + anchor_vp.y()
2497
+
2498
+ # Convert to image coords at old scale
2499
+ xi = x_label_pre / max(old_scale, 1e-12)
2500
+ yi = y_label_pre / max(old_scale, 1e-12)
2501
+
2502
+ # Apply scale and redraw (updates label size + scrollbar ranges)
2503
+ self.scale = new_scale
2504
+ self._render(rebuild=False)
2505
+
2506
+ # Reproject that image point to label coords at new scale
2507
+ x_label_post = xi * new_scale
2508
+ y_label_post = yi * new_scale
2509
+
2510
+ # Desired scrollbar values to keep point under the cursor
2511
+ new_h = int(round(x_label_post - anchor_vp.x()))
2512
+ new_v = int(round(y_label_post - anchor_vp.y()))
2513
+
2514
+ # Clamp to valid range
2515
+ new_h = max(hbar.minimum(), min(new_h, hbar.maximum()))
2516
+ new_v = max(vbar.minimum(), min(new_v, vbar.maximum()))
2517
+
2518
+ # Apply
2519
+ hbar.setValue(new_h)
2520
+ vbar.setValue(new_v)
2521
+ self._schedule_emit_view_transform()
2522
+
2523
+ def _find_main_window(self):
2524
+ p = self.parent()
2525
+ while p is not None and not hasattr(p, "docman"):
2526
+ p = p.parent()
2527
+ return p
2528
+
2529
+ def eventFilter(self, obj, ev):
2530
+ is_on_view = (obj is self.label) or (obj is self.scroll.viewport())
2531
+
2532
+ # 0) PREVIEW-SELECT MODE: consume mouse events first so earlier branches don't steal them
2533
+ if self._preview_select_mode and is_on_view:
2534
+ vp = self.scroll.viewport()
2535
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
2536
+ vp_pos = obj.mapTo(vp, ev.pos())
2537
+ self._rubber_origin = vp_pos
2538
+ if self._rubber is None:
2539
+ self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, vp)
2540
+ self._rubber.setGeometry(QRect(self._rubber_origin, QSize(1, 1)))
2541
+ self._rubber.show()
2542
+ ev.accept(); return True
2543
+
2544
+ if ev.type() == QEvent.Type.MouseMove and self._rubber is not None and self._rubber_origin is not None:
2545
+ vp_pos = obj.mapTo(vp, ev.pos())
2546
+ rect = QRect(self._rubber_origin, vp_pos).normalized()
2547
+ self._rubber.setGeometry(rect)
2548
+ ev.accept(); return True
2549
+
2550
+ if ev.type() == QEvent.Type.MouseButtonRelease and self._rubber is not None and self._rubber_origin is not None:
2551
+ vp_pos = obj.mapTo(vp, ev.pos())
2552
+ rect = QRect(self._rubber_origin, vp_pos).normalized()
2553
+ self._finish_preview_rect(rect)
2554
+ ev.accept(); return True
2555
+ # don’t swallow unrelated events
2556
+
2557
+ # 1) Ctrl + wheel → zoom
2558
+ if ev.type() == QEvent.Type.Wheel:
2559
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
2560
+ factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
2561
+ self._zoom_at_anchor(factor)
2562
+ return True
2563
+ return False
2564
+
2565
+ # 2) Space+click → start readout
2566
+ if ev.type() == QEvent.Type.MouseButtonPress:
2567
+ if self._space_down and ev.button() == Qt.MouseButton.LeftButton:
2568
+ vp_pos = obj.mapTo(self.scroll.viewport(), ev.pos())
2569
+ res = self._sample_image_at_viewport_pos(vp_pos)
2570
+ if res is not None:
2571
+ xi, yi, sample = res
2572
+ self._show_readout(xi, yi, sample)
2573
+ self._readout_dragging = True
2574
+ return True
2575
+ return False
2576
+
2577
+ # 3) Space+drag → live readout
2578
+ if ev.type() == QEvent.Type.MouseMove:
2579
+ if self._readout_dragging:
2580
+ vp_pos = obj.mapTo(self.scroll.viewport(), ev.pos())
2581
+ res = self._sample_image_at_viewport_pos(vp_pos)
2582
+ if res is not None:
2583
+ xi, yi, sample = res
2584
+ self._show_readout(xi, yi, sample)
2585
+ return True
2586
+ return False
2587
+
2588
+ # 4) Release → stop live readout
2589
+ if ev.type() == QEvent.Type.MouseButtonRelease:
2590
+ if self._readout_dragging:
2591
+ self._readout_dragging = False
2592
+ return True
2593
+ return False
2594
+
2595
+ return super().eventFilter(obj, ev)
2596
+
2597
+
2598
+ def _finish_preview_rect(self, vp_rect: QRect):
2599
+ # Map viewport rectangle into image coordinates
2600
+ if vp_rect.width() < 4 or vp_rect.height() < 4:
2601
+ self._cancel_rubber()
2602
+ return
2603
+
2604
+ hbar = self.scroll.horizontalScrollBar()
2605
+ vbar = self.scroll.verticalScrollBar()
2606
+
2607
+ # Upper-left in label coords
2608
+ x_label0 = hbar.value() + vp_rect.left()
2609
+ y_label0 = vbar.value() + vp_rect.top()
2610
+ x_label1 = hbar.value() + vp_rect.right()
2611
+ y_label1 = vbar.value() + vp_rect.bottom()
2612
+
2613
+ s = max(self.scale, 1e-12)
2614
+
2615
+ x0 = int(round(x_label0 / s))
2616
+ y0 = int(round(y_label0 / s))
2617
+ x1 = int(round(x_label1 / s))
2618
+ y1 = int(round(y_label1 / s))
2619
+
2620
+ if x1 <= x0 or y1 <= y0:
2621
+ self._cancel_rubber()
2622
+ return
2623
+
2624
+ roi = (x0, y0, x1 - x0, y1 - y0)
2625
+ self._create_preview_from_roi(roi)
2626
+ self._cancel_rubber()
2627
+
2628
+ def _create_preview_from_roi(self, roi: tuple[int,int,int,int]):
2629
+ """
2630
+ roi: (x, y, w, h) in FULL IMAGE coordinates
2631
+ """
2632
+ arr = np.asarray(self.document.image)
2633
+ H, W = (arr.shape[0], arr.shape[1]) if arr.ndim >= 2 else (0, 0)
2634
+ x, y, w, h = roi
2635
+ # clamp to image bounds
2636
+ x = max(0, min(x, max(0, W-1)))
2637
+ y = max(0, min(y, max(0, H-1)))
2638
+ w = max(1, min(w, W - x))
2639
+ h = max(1, min(h, H - y))
2640
+
2641
+ crop = arr[y:y+h, x:x+w].copy() # isolate for preview
2642
+
2643
+ pid = self._next_preview_id
2644
+ self._next_preview_id += 1
2645
+ name = f"Preview {pid} ({w}×{h})"
2646
+
2647
+ self._previews.append({"id": pid, "name": name, "roi": (x, y, w, h), "arr": crop})
2648
+
2649
+ # Build a tab with a simple QLabel viewer (reuses global rendering through _render)
2650
+ host = QWidget(self)
2651
+ l = QVBoxLayout(host); l.setContentsMargins(0,0,0,0)
2652
+ # For simplicity, we reuse the SAME scroll/label pipeline; the source image is switched in _render
2653
+ # but we still want a local label so the tab displays something. Make a tiny label holder:
2654
+ holder = QLabel(" ") # placeholder; we still render into self.label (single view)
2655
+ holder.setMinimumHeight(1)
2656
+ l.addWidget(holder)
2657
+
2658
+ host._preview_id = pid # attach id for lookups
2659
+ idx = self._tabs.addTab(host, name)
2660
+ self._tabs.setCurrentIndex(idx)
2661
+ self._tabs.tabBar().setVisible(True) # show tabs when first preview appears
2662
+
2663
+ # Switch active source and redraw
2664
+ self._active_source_kind = "preview"
2665
+ self._active_preview_id = pid
2666
+ self._render(True)
2667
+ self._update_replay_button()
2668
+ mw = self._find_main_window()
2669
+ if mw is not None and getattr(mw, "_auto_fit_on_resize", False):
2670
+ try:
2671
+ mw._zoom_active_fit()
2672
+ except Exception:
2673
+ pass
2674
+
2675
+ def mousePressEvent(self, e):
2676
+ # If we're defining a preview ROI, don't start panning here
2677
+ if self._preview_select_mode:
2678
+ e.ignore() # let the eventFilter (label/viewport) handle it
2679
+ return
2680
+
2681
+ if e.button() == Qt.MouseButton.LeftButton:
2682
+ if self._space_down:
2683
+ vp = self.scroll.viewport()
2684
+ vp_pos = vp.mapFrom(self, e.pos())
2685
+ res = self._sample_image_at_viewport_pos(vp_pos)
2686
+ if res is not None:
2687
+ xi, yi, sample = res
2688
+ self._show_readout(xi, yi, sample)
2689
+ self._readout_dragging = True
2690
+ return
2691
+
2692
+ # normal pan mode
2693
+ self._dragging = True
2694
+ self._pan_live = True
2695
+ self._drag_start = e.pos()
2696
+
2697
+ # NEW: emit once at drag start so linked views sync instantly
2698
+ self._emit_view_transform()
2699
+ return
2700
+
2701
+ super().mousePressEvent(e)
2702
+
2703
+
2704
+
2705
+ def _show_readout(self, xi, yi, sample):
2706
+ mw = self._find_main_window()
2707
+ if mw is None:
2708
+ return
2709
+
2710
+ # We want raw float prints, never 16-bit normalized
2711
+ r = g = b = None
2712
+ k = None
2713
+
2714
+ if isinstance(sample, dict):
2715
+ # 1) the clean mono path
2716
+ if "mono" in sample:
2717
+ try:
2718
+ k = float(sample["mono"])
2719
+ except Exception:
2720
+ k = sample["mono"]
2721
+ # 2) the clean RGB path
2722
+ elif all(ch in sample for ch in ("r", "g", "b")):
2723
+ try:
2724
+ r = float(sample["r"])
2725
+ g = float(sample["g"])
2726
+ b = float(sample["b"])
2727
+ except Exception:
2728
+ r = sample["r"]; g = sample["g"]; b = sample["b"]
2729
+ else:
2730
+ # 3) weird dict → just take the first numeric-looking value
2731
+ for v in sample.values():
2732
+ try:
2733
+ k = float(v)
2734
+ break
2735
+ except Exception:
2736
+ continue
2737
+
2738
+ elif isinstance(sample, (list, tuple)):
2739
+ if len(sample) == 1:
2740
+ try:
2741
+ k = float(sample[0])
2742
+ except Exception:
2743
+ k = sample[0]
2744
+ elif len(sample) >= 3:
2745
+ try:
2746
+ r = float(sample[0]); g = float(sample[1]); b = float(sample[2])
2747
+ except Exception:
2748
+ r, g, b = sample[0], sample[1], sample[2]
2749
+
2750
+ else:
2751
+ # numpy scalar / plain number
2752
+ try:
2753
+ k = float(sample)
2754
+ except Exception:
2755
+ k = sample
2756
+
2757
+ msg = f"x={xi} y={yi}"
2758
+
2759
+ if r is not None and g is not None and b is not None:
2760
+ msg += f" R={r:.6f} G={g:.6f} B={b:.6f}"
2761
+ elif k is not None:
2762
+ msg += f" K={k:.6f}"
2763
+ else:
2764
+ # final fallback if everything was weird
2765
+ msg += " K=?"
2766
+
2767
+ # ---- WCS ----
2768
+ wcs2 = self._get_celestial_wcs()
2769
+ if wcs2 is not None:
2770
+ try:
2771
+ ra_deg, dec_deg = map(float, wcs2.pixel_to_world_values(float(xi), float(yi)))
2772
+
2773
+ # RA
2774
+ ra_h = ra_deg / 15.0
2775
+ ra_hh = int(ra_h)
2776
+ ra_mm = int((ra_h - ra_hh) * 60.0)
2777
+ ra_ss = ((ra_h - ra_hh) * 60.0 - ra_mm) * 60.0
2778
+
2779
+ # Dec
2780
+ sign = "+" if dec_deg >= 0 else "-"
2781
+ d = abs(dec_deg)
2782
+ dec_dd = int(d)
2783
+ dec_mm = int((d - dec_dd) * 60.0)
2784
+ dec_ss = ((d - dec_dd) * 60.0 - dec_mm) * 60.0
2785
+
2786
+ msg += (
2787
+ f" RA={ra_hh:02d}:{ra_mm:02d}:{ra_ss:05.2f}"
2788
+ f" Dec={sign}{dec_dd:02d}:{dec_mm:02d}:{dec_ss:05.2f}"
2789
+ )
2790
+ except Exception:
2791
+ pass
2792
+
2793
+ mw.statusBar().showMessage(msg)
2794
+
2795
+
2796
+
2797
+ # 1) helper to build ROI-adjusted WCS (keeps projection/rotation/CD/PC intact)
2798
+ def _wcs_for_roi(self, base_wcs, roi, arr_shape=None):
2799
+ # roi = (x, y, w, h) in FULL-image pixel coords
2800
+ import numpy as np
2801
+ if base_wcs is None or roi is None:
2802
+ return base_wcs
2803
+ x, y, w, h = map(int, roi)
2804
+ wnew = base_wcs.deepcopy()
2805
+ # shift reference pixel into the cropped frame
2806
+ wnew.wcs.crpix = wnew.wcs.crpix - np.array([float(x), float(y)], dtype=float)
2807
+ # tell astropy the new image size for grid/edge computations
2808
+ try:
2809
+ wnew.array_shape = (h, w)
2810
+ wnew.pixel_shape = (w, h)
2811
+ except Exception:
2812
+ pass
2813
+ # prefer 2-D celestial
2814
+ try:
2815
+ cel = getattr(wnew, "celestial", None)
2816
+ if cel is not None and getattr(cel, "naxis", 2) == 2:
2817
+ return cel
2818
+ except Exception:
2819
+ pass
2820
+ return wnew
2821
+
2822
+
2823
+ # 2) make _get_celestial_wcs ROI-aware
2824
+ def _get_celestial_wcs(self):
2825
+ """
2826
+ Return the *correct* celestial WCS for whatever the user is actually
2827
+ seeing in this view.
2828
+
2829
+ - On the Full tab: just use the document's WCS / header.
2830
+ - On a Preview tab: prefer the ROI backing doc's WCS from DocManager.
2831
+ If that's not available, synthesize a cropped header from the base
2832
+ header + preview ROI via _compute_cropped_wcs().
2833
+ """
2834
+ doc = getattr(self, "document", None)
2835
+ if doc is None:
2836
+ return None
2837
+
2838
+ # -----------------------------
2839
+ # FULL IMAGE (no preview active)
2840
+ # -----------------------------
2841
+ if not self.has_active_preview():
2842
+ meta = getattr(doc, "metadata", {}) or {}
2843
+ w = meta.get("wcs")
2844
+ if isinstance(w, _AstroWCS):
2845
+ try:
2846
+ wc = getattr(w, "celestial", None)
2847
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else w
2848
+ except Exception:
2849
+ return w
2850
+
2851
+ hdr = (
2852
+ meta.get("original_header")
2853
+ or meta.get("fits_header")
2854
+ or meta.get("header")
2855
+ )
2856
+ if hdr is None:
2857
+ return None
2858
+
2859
+ w = build_celestial_wcs(hdr)
2860
+ if w is not None:
2861
+ meta["wcs"] = w
2862
+ return w
2863
+
2864
+ # -----------------------------
2865
+ # PREVIEW TAB (ROI view)
2866
+ # -----------------------------
2867
+ roi = self.current_preview_roi()
2868
+ if roi is None:
2869
+ return None
2870
+
2871
+ # Base document is the full image doc; backing_doc may be the ROI doc
2872
+ base_doc = getattr(self, "base_document", None) or doc
2873
+ base_meta = getattr(base_doc, "metadata", {}) or {}
2874
+
2875
+ dm = getattr(self, "_docman", None)
2876
+ backing_doc = None
2877
+ if dm is not None:
2878
+ try:
2879
+ backing_doc = dm.get_document_for_view(self)
2880
+ except Exception:
2881
+ backing_doc = None
2882
+
2883
+ # 1) If DocManager has a separate ROI doc for this view, use ITS WCS
2884
+ if backing_doc is not None and backing_doc is not base_doc:
2885
+ bmeta = getattr(backing_doc, "metadata", {}) or {}
2886
+ w = bmeta.get("wcs")
2887
+ if isinstance(w, _AstroWCS):
2888
+ try:
2889
+ wc = getattr(w, "celestial", None)
2890
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else w
2891
+ except Exception:
2892
+ return w
2893
+
2894
+ hdr = (
2895
+ bmeta.get("original_header")
2896
+ or bmeta.get("fits_header")
2897
+ or bmeta.get("header")
2898
+ )
2899
+ if hdr is not None:
2900
+ w = build_celestial_wcs(hdr)
2901
+ if w is not None:
2902
+ bmeta["wcs"] = w
2903
+ return w
2904
+
2905
+ # 2) Fallback: synthesize cropped WCS from base header + ROI
2906
+ hdr_full = (
2907
+ base_meta.get("original_header")
2908
+ or base_meta.get("fits_header")
2909
+ or base_meta.get("header")
2910
+ )
2911
+ if hdr_full is None:
2912
+ return None
2913
+
2914
+ cache_key = f"_preview_wcs_{self._active_preview_id}"
2915
+ cached = base_meta.get(cache_key)
2916
+ if isinstance(cached, _AstroWCS):
2917
+ try:
2918
+ wc = getattr(cached, "celestial", None)
2919
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else cached
2920
+ except Exception:
2921
+ pass
2922
+
2923
+ try:
2924
+ x, y, w, h = map(int, roi)
2925
+ cropped_hdr = _compute_cropped_wcs(hdr_full, x, y, w, h)
2926
+ wcs = build_celestial_wcs(cropped_hdr)
2927
+ except Exception:
2928
+ wcs = None
2929
+
2930
+ if wcs is not None:
2931
+ base_meta[cache_key] = wcs
2932
+ return wcs
2933
+
2934
+
2935
+ def _extract_wcs_from_doc(self):
2936
+ """
2937
+ Try to get an astropy WCS from the current document or a sensible parent.
2938
+ Caches the resolved WCS on whichever doc we pulled it from.
2939
+ """
2940
+ doc = getattr(self, "document", None)
2941
+ if doc is None:
2942
+ return None
2943
+
2944
+ def _try_on_meta(meta: dict):
2945
+ # (1) literal WCS object stored?
2946
+ w = meta.get("wcs")
2947
+ if isinstance(w, _AstroWCS):
2948
+ return w
2949
+ # (2) any header-like thing present?
2950
+ hdr = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
2951
+ return build_celestial_wcs(hdr)
2952
+
2953
+ # 1) current doc (+ cache)
2954
+ meta = getattr(doc, "metadata", {}) or {}
2955
+ if "_astropy_wcs" in meta:
2956
+ return meta["_astropy_wcs"]
2957
+ w = _try_on_meta(meta)
2958
+ if w is not None:
2959
+ meta["_astropy_wcs"] = w
2960
+ return w
2961
+
2962
+ # 2) likely parents/sources
2963
+ candidates = []
2964
+
2965
+ base = getattr(self, "base_document", None)
2966
+ if base is not None and base is not doc:
2967
+ candidates.append(base)
2968
+
2969
+ dm = getattr(self, "_docman", None)
2970
+ if dm is not None and hasattr(dm, "get_document_for_view"):
2971
+ try:
2972
+ src = dm.get_document_for_view(self)
2973
+ if src is not None and src is not doc and src is not base:
2974
+ candidates.append(src)
2975
+ except Exception:
2976
+ pass
2977
+
2978
+ src_uid = meta.get("wcs_source_doc_uid") or meta.get("base_doc_uid")
2979
+ if src_uid is not None:
2980
+ try:
2981
+ from setiastro.saspro.doc_manager import DocManager
2982
+ reg = getattr(DocManager, "_global_registry", {})
2983
+ by_uid = reg.get(src_uid)
2984
+ if by_uid and by_uid not in candidates and by_uid is not doc and by_uid is not base:
2985
+ candidates.append(by_uid)
2986
+ except Exception:
2987
+ pass
2988
+
2989
+ for cand in candidates:
2990
+ m = getattr(cand, "metadata", {}) or {}
2991
+ if "_astropy_wcs" in m:
2992
+ meta["_astropy_wcs"] = m["_astropy_wcs"]
2993
+ return m["_astropy_wcs"]
2994
+ w = _try_on_meta(m)
2995
+ if w is not None:
2996
+ m["_astropy_wcs"] = w
2997
+ meta["_astropy_wcs"] = w
2998
+ return w
2999
+
3000
+ return None
3001
+
3002
+
3003
+
3004
+ def mouseMoveEvent(self, e):
3005
+ # While defining preview ROI, let the eventFilter drive the QRubberBand
3006
+ if self._preview_select_mode:
3007
+ e.ignore()
3008
+ return
3009
+
3010
+ if self._readout_dragging:
3011
+ vp = self.scroll.viewport()
3012
+ vp_pos = vp.mapFrom(self, e.pos())
3013
+ res = self._sample_image_at_viewport_pos(vp_pos)
3014
+ if res is not None:
3015
+ xi, yi, sample = res
3016
+ self._show_readout(xi, yi, sample)
3017
+ return
3018
+
3019
+ if self._dragging:
3020
+ delta = e.pos() - self._drag_start
3021
+ self.scroll.horizontalScrollBar().setValue(self.scroll.horizontalScrollBar().value() - delta.x())
3022
+ self.scroll.verticalScrollBar().setValue(self.scroll.verticalScrollBar().value() - delta.y())
3023
+ self._drag_start = e.pos()
3024
+ # live emit happens via _on_scroll_changed(), but this is a nice extra nudge:
3025
+ self._emit_view_transform_now()
3026
+ return
3027
+
3028
+ super().mouseMoveEvent(e)
3029
+
3030
+
3031
+
3032
+ def mouseReleaseEvent(self, e):
3033
+ if self._preview_select_mode:
3034
+ e.ignore(); return
3035
+ if e.button() == Qt.MouseButton.LeftButton:
3036
+ self._dragging = False
3037
+ self._pan_live = False # ← back to debounced mode
3038
+ self._readout_dragging = False
3039
+ self._emit_view_transform()
3040
+ return
3041
+ super().mouseReleaseEvent(e)
3042
+
3043
+
3044
+ def closeEvent(self, e):
3045
+ mw = self._find_main_window()
3046
+ doc = getattr(self, "document", None)
3047
+
3048
+ # If main window is force-closing (global exit accepted), don't ask.
3049
+ force = bool(getattr(mw, "_force_close_all", False))
3050
+
3051
+ if not force and doc is not None:
3052
+ # Ask only if this doc has edits
3053
+ should_warn = False
3054
+ if mw and hasattr(mw, "_document_has_edits"):
3055
+ should_warn = mw._document_has_edits(doc)
3056
+ else:
3057
+ # Fallback if called standalone
3058
+ try:
3059
+ should_warn = bool(doc.can_undo())
3060
+ except Exception:
3061
+ should_warn = False
3062
+
3063
+ if should_warn:
3064
+ r = QMessageBox.question(
3065
+ self, "Close Image?",
3066
+ "This image has edits that aren’t applied/saved.\nClose anyway?",
3067
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
3068
+ QMessageBox.StandardButton.No
3069
+ )
3070
+ if r != QMessageBox.StandardButton.Yes:
3071
+ e.ignore()
3072
+ return
3073
+
3074
+ try:
3075
+ if hasattr(self, "_docman") and self._docman is not None:
3076
+ self._docman.imageRegionUpdated.disconnect(self._on_doc_region_updated)
3077
+ # NEW: also drop the nudge hook(s)
3078
+ try:
3079
+ self._docman.imageRegionUpdated.disconnect(self._on_docman_nudge)
3080
+ except Exception:
3081
+ pass
3082
+ if hasattr(self._docman, "previewRepaintRequested"):
3083
+ try:
3084
+ self._docman.previewRepaintRequested.disconnect(self._on_docman_nudge)
3085
+ except Exception:
3086
+ pass
3087
+ except Exception:
3088
+ pass
3089
+ try:
3090
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
3091
+ if base is not None:
3092
+ base.changed.disconnect(self._on_base_doc_changed)
3093
+ except Exception:
3094
+ pass
3095
+ try:
3096
+ self.unlink_all()
3097
+ except Exception:
3098
+ pass
3099
+ try:
3100
+ if id(self) in ImageSubWindow._registry:
3101
+ ImageSubWindow._registry.pop(id(self), None)
3102
+ except Exception:
3103
+ pass
3104
+ # proceed with your current teardown
3105
+ try:
3106
+ # emit your existing signal if you have it
3107
+ if hasattr(self, "aboutToClose"):
3108
+ self.aboutToClose.emit(doc)
3109
+ except Exception:
3110
+ pass
3111
+ super().closeEvent(e)
3112
+
3113
+ def _resolve_history_doc(self):
3114
+ """
3115
+ Return the doc whose history we should mutate:
3116
+ - If a Preview tab is active → the ROI/proxy doc from DocManager
3117
+ - Otherwise → the base/full document
3118
+ """
3119
+ # Prefer DocManager's ROI-aware mapping if present
3120
+ dm = getattr(self, "_docman", None)
3121
+ if (self._active_source_kind == "preview"
3122
+ and self._active_preview_id is not None
3123
+ and dm is not None
3124
+ and hasattr(dm, "get_document_for_view")):
3125
+ try:
3126
+ d = dm.get_document_for_view(self)
3127
+ if d is not None:
3128
+ return d
3129
+ except Exception:
3130
+ pass
3131
+ # Fallback to the main doc
3132
+ return getattr(self, "document", None)
3133
+
3134
+
3135
+ def _refresh_local_undo_buttons(self):
3136
+ """Enable/disable the local Undo/Redo toolbuttons based on can_undo/can_redo."""
3137
+ try:
3138
+ doc = self._resolve_history_doc()
3139
+ can_u = bool(doc and hasattr(doc, "can_undo") and doc.can_undo())
3140
+ can_r = bool(doc and hasattr(doc, "can_redo") and doc.can_redo())
3141
+ except Exception:
3142
+ can_u = can_r = False
3143
+
3144
+ b_u = getattr(self, "_btn_undo", None)
3145
+ b_r = getattr(self, "_btn_redo", None)
3146
+
3147
+ try:
3148
+ if b_u: b_u.setEnabled(can_u)
3149
+ except RuntimeError:
3150
+ return
3151
+ except Exception:
3152
+ pass
3153
+ try:
3154
+ if b_r: b_r.setEnabled(can_r)
3155
+ except RuntimeError:
3156
+ return
3157
+ except Exception:
3158
+ pass
3159
+
3160
+
3161
+
3162
+ # --- NEW: TableSubWindow -------------------------------------------------
3163
+ from PyQt6.QtWidgets import QTableView, QPushButton, QFileDialog
3164
+
3165
+ class TableSubWindow(QWidget):
3166
+ """
3167
+ Lightweight subwindow to render TableDocument (rows/headers) in a QTableView.
3168
+ Provides: copy, export CSV, row count display.
3169
+ """
3170
+ viewTitleChanged = pyqtSignal(object, str) # to mirror ImageSubWindow emissions (if needed)
3171
+
3172
+ def __init__(self, table_document, parent=None):
3173
+ super().__init__(parent)
3174
+ self.document = table_document
3175
+ self._last_title_for_emit = None
3176
+
3177
+ lyt = QVBoxLayout(self)
3178
+ title_row = QHBoxLayout()
3179
+ self.title_lbl = QLabel(self.document.display_name())
3180
+ title_row.addWidget(self.title_lbl)
3181
+ title_row.addStretch(1)
3182
+
3183
+ self.export_btn = QPushButton("Export CSV…")
3184
+ self.export_btn.clicked.connect(self._export_csv)
3185
+ title_row.addWidget(self.export_btn)
3186
+ lyt.addLayout(title_row)
3187
+
3188
+ self.table = QTableView(self)
3189
+ self.table.setSortingEnabled(True)
3190
+ self.table.setAlternatingRowColors(True)
3191
+ self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
3192
+ self.table.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
3193
+ lyt.addWidget(self.table, 1)
3194
+
3195
+ rows = getattr(self.document, "rows", [])
3196
+ headers = getattr(self.document, "headers", [])
3197
+ self._model = SimpleTableModel(rows, headers, self)
3198
+ self.table.setModel(self._model)
3199
+ self.table.horizontalHeader().setStretchLastSection(True)
3200
+ self.table.resizeColumnsToContents()
3201
+
3202
+ self._sync_host_title()
3203
+ #print(f"[TableSubWindow] init rows={self._model.rowCount()} cols={self._model.columnCount()} title='{self.document.display_name()}'")
3204
+ # react to doc rename if you add such behavior later
3205
+ try:
3206
+ self.document.changed.connect(self._on_doc_changed)
3207
+ except Exception:
3208
+ pass
3209
+
3210
+ def _on_doc_changed(self):
3211
+ # if title changes or content updates in future
3212
+ self.title_lbl.setText(self.document.display_name())
3213
+ self._sync_host_title()
3214
+
3215
+ def _mdi_subwindow(self) -> QMdiSubWindow | None:
3216
+ w = self.parent()
3217
+ while w is not None and not isinstance(w, QMdiSubWindow):
3218
+ w = w.parent()
3219
+ return w
3220
+
3221
+ def _sync_host_title(self):
3222
+ sub = self._mdi_subwindow()
3223
+ if not sub:
3224
+ return
3225
+ title = self.document.display_name()
3226
+ if title != sub.windowTitle():
3227
+ sub.setWindowTitle(title)
3228
+ sub.setToolTip(title)
3229
+ if title != self._last_title_for_emit:
3230
+ self._last_title_for_emit = title
3231
+ try:
3232
+ self.viewTitleChanged.emit(self, title)
3233
+ except Exception:
3234
+ pass
3235
+
3236
+ def _export_csv(self):
3237
+ # Prefer already-exported CSV from metadata when available, otherwise prompt
3238
+ existing = self.document.metadata.get("table_csv")
3239
+ if existing and os.path.exists(existing):
3240
+ # Offer to open/save-as that CSV
3241
+ dst, ok = QFileDialog.getSaveFileName(self, "Save CSV As…", os.path.basename(existing), "CSV Files (*.csv)")
3242
+ if ok and dst:
3243
+ try:
3244
+ import shutil
3245
+ shutil.copyfile(existing, dst)
3246
+ except Exception as e:
3247
+ QMessageBox.warning(self, "Export CSV", f"Failed to copy CSV:\n{e}")
3248
+ return
3249
+
3250
+ # No pre-export → write one from the model
3251
+ dst, ok = QFileDialog.getSaveFileName(self, "Export CSV…", "table.csv", "CSV Files (*.csv)")
3252
+ if not ok or not dst:
3253
+ return
3254
+ try:
3255
+ import csv
3256
+ with open(dst, "w", encoding="utf-8", newline="") as f:
3257
+ w = csv.writer(f)
3258
+ # headers
3259
+ cols = self._model.columnCount()
3260
+ hdrs = [self._model.headerData(c, Qt.Orientation.Horizontal) for c in range(cols)]
3261
+ w.writerow([str(h) for h in hdrs])
3262
+ # rows
3263
+ rows = self._model.rowCount()
3264
+ for r in range(rows):
3265
+ w.writerow([self._model.data(self._model.index(r, c), Qt.ItemDataRole.DisplayRole) for c in range(cols)])
3266
+ except Exception as e:
3267
+ QMessageBox.warning(self, "Export CSV", f"Failed to export CSV:\n{e}")