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,299 @@
1
+ # pro/widgets/wavelet_utils.py
2
+ """
3
+ Shared wavelet utilities for à-trous decomposition and reconstruction.
4
+
5
+ This module provides centralized implementations for wavelet operations
6
+ used across wavescale_hdr.py, wavescalede.py, and other modules.
7
+ """
8
+ from __future__ import annotations
9
+ import numpy as np
10
+
11
+
12
+ # ─────────────────────────────────────────────────────────────────────────────
13
+ # Convolution helpers (SciPy if available; otherwise a separable fallback)
14
+ # ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ try:
17
+ from scipy.ndimage import convolve as _nd_convolve
18
+ from scipy.ndimage import gaussian_filter as _nd_gauss
19
+ _HAVE_SCIPY = True
20
+ except ImportError:
21
+ _HAVE_SCIPY = False
22
+ _nd_convolve = None
23
+ _nd_gauss = None
24
+
25
+
26
+ def conv_sep_reflect(image2d: np.ndarray, k1d: np.ndarray, axis: int) -> np.ndarray:
27
+ """
28
+ Separable 1D convolution along a given axis with reflect padding.
29
+
30
+ Uses scipy.ndimage.convolve if available, otherwise falls back to numpy.
31
+
32
+ Args:
33
+ image2d: 2D input array
34
+ k1d: 1D kernel
35
+ axis: 0 for vertical (y), 1 for horizontal (x)
36
+
37
+ Returns:
38
+ Convolved array, same shape as input
39
+ """
40
+ if _HAVE_SCIPY:
41
+ if axis == 1: # x
42
+ return _nd_convolve(image2d, k1d.reshape(1, -1), mode="reflect")
43
+ else: # y
44
+ return _nd_convolve(image2d, k1d.reshape(-1, 1), mode="reflect")
45
+ else:
46
+ # Fallback numpy implementation
47
+ image2d = np.asarray(image2d, dtype=np.float32)
48
+ k1d = np.asarray(k1d, dtype=np.float32)
49
+ r = len(k1d) // 2
50
+ if axis == 1: # horizontal
51
+ pad = np.pad(image2d, ((0, 0), (r, r)), mode="reflect")
52
+ out = np.empty_like(image2d, dtype=np.float32)
53
+ for i in range(image2d.shape[0]):
54
+ out[i] = np.convolve(pad[i], k1d, mode="valid")
55
+ return out
56
+ else: # vertical
57
+ pad = np.pad(image2d, ((r, r), (0, 0)), mode="reflect")
58
+ out = np.empty_like(image2d, dtype=np.float32)
59
+ for j in range(image2d.shape[1]):
60
+ out[:, j] = np.convolve(pad[:, j], k1d, mode="valid")
61
+ return out
62
+
63
+
64
+ def gauss1d(sigma: float) -> np.ndarray:
65
+ """
66
+ Create a 1D Gaussian kernel.
67
+
68
+ Args:
69
+ sigma: Standard deviation of the Gaussian
70
+
71
+ Returns:
72
+ Normalized 1D Gaussian kernel
73
+ """
74
+ if sigma <= 0:
75
+ return np.array([1.0], dtype=np.float32)
76
+ radius = max(1, int(round(3.0 * sigma)))
77
+ x = np.arange(-radius, radius + 1, dtype=np.float32)
78
+ k = np.exp(-0.5 * (x / sigma)**2)
79
+ k /= np.sum(k)
80
+ return k.astype(np.float32)
81
+
82
+
83
+ def gauss_blur(image2d: np.ndarray, sigma: float) -> np.ndarray:
84
+ """
85
+ Apply Gaussian blur to a 2D image.
86
+
87
+ Uses scipy.ndimage.gaussian_filter if available, otherwise separable convolution.
88
+
89
+ Args:
90
+ image2d: 2D input array
91
+ sigma: Standard deviation of the Gaussian
92
+
93
+ Returns:
94
+ Blurred array
95
+ """
96
+ if _HAVE_SCIPY and _nd_gauss is not None:
97
+ return _nd_gauss(image2d, sigma=sigma, mode="reflect")
98
+ else:
99
+ k = gauss1d(float(sigma))
100
+ tmp = conv_sep_reflect(image2d, k, axis=1)
101
+ return conv_sep_reflect(tmp, k, axis=0)
102
+
103
+
104
+ # ─────────────────────────────────────────────────────────────────────────────
105
+ # À-trous wavelet transform (B3 spline kernel)
106
+ # ─────────────────────────────────────────────────────────────────────────────
107
+
108
+ # Standard B3-spline kernel for à-trous
109
+ B3_KERNEL = np.array([1, 4, 6, 4, 1], dtype=np.float32) / 16.0
110
+
111
+
112
+ def build_spaced_kernel(kernel: np.ndarray, scale_idx: int) -> np.ndarray:
113
+ """
114
+ Build a spaced (à-trous) kernel for a given scale.
115
+
116
+ Args:
117
+ kernel: Base 1D kernel
118
+ scale_idx: Scale index (0 = no spacing, 1 = step 2, 2 = step 4, etc.)
119
+
120
+ Returns:
121
+ Spaced kernel with zeros inserted
122
+ """
123
+ if scale_idx == 0:
124
+ return kernel.astype(np.float32, copy=False)
125
+ step = 2 ** scale_idx
126
+ spaced_len = len(kernel) + (len(kernel) - 1) * (step - 1)
127
+ spaced = np.zeros(spaced_len, dtype=np.float32)
128
+ spaced[0::step] = kernel
129
+ return spaced
130
+
131
+
132
+ def atrous_decompose(img2d: np.ndarray, n_scales: int,
133
+ base_kernel: np.ndarray | None = None) -> list[np.ndarray]:
134
+ """
135
+ Perform à-trous (undecimated) wavelet decomposition.
136
+
137
+ Args:
138
+ img2d: 2D input image
139
+ n_scales: Number of detail scales to extract
140
+ base_kernel: Base kernel (default: B3 spline)
141
+
142
+ Returns:
143
+ List of [detail_0, detail_1, ..., detail_n-1, residual]
144
+ where detail_i is the wavelet plane at scale i
145
+ """
146
+ if base_kernel is None:
147
+ base_kernel = B3_KERNEL
148
+
149
+ current = img2d.astype(np.float32, copy=True)
150
+ planes: list[np.ndarray] = []
151
+
152
+ for s in range(n_scales):
153
+ k = build_spaced_kernel(base_kernel, s)
154
+ tmp = conv_sep_reflect(current, k, axis=1)
155
+ smooth = conv_sep_reflect(tmp, k, axis=0)
156
+ planes.append(current - smooth) # detail = current - smoothed
157
+ current = smooth
158
+
159
+ planes.append(current) # residual (lowest frequency)
160
+ return planes
161
+
162
+
163
+ def atrous_reconstruct(planes: list[np.ndarray]) -> np.ndarray:
164
+ """
165
+ Reconstruct image from à-trous wavelet planes.
166
+
167
+ Args:
168
+ planes: List of [detail_0, ..., detail_n-1, residual]
169
+
170
+ Returns:
171
+ Reconstructed image
172
+ """
173
+ out = planes[-1].astype(np.float32, copy=True) # start with residual
174
+ for detail in planes[:-1]:
175
+ out += detail
176
+ return out
177
+
178
+
179
+ # ─────────────────────────────────────────────────────────────────────────────
180
+ # Color space utilities (with optional Numba acceleration)
181
+ # ─────────────────────────────────────────────────────────────────────────────
182
+
183
+ try:
184
+ from setiastro.saspro.legacy.numba_utils import (
185
+ rgb_to_xyz_numba, xyz_to_lab_numba,
186
+ lab_to_xyz_numba, xyz_to_rgb_numba,
187
+ )
188
+ _HAVE_NUMBA = True
189
+ except ImportError:
190
+ _HAVE_NUMBA = False
191
+
192
+
193
+ # sRGB -> XYZ transformation matrix
194
+ _RGB_TO_XYZ_MATRIX = np.array([
195
+ [0.4124564, 0.3575761, 0.1804375],
196
+ [0.2126729, 0.7151522, 0.0721750],
197
+ [0.0193339, 0.1191920, 0.9503041]
198
+ ], dtype=np.float32)
199
+
200
+ # XYZ -> sRGB transformation matrix (inverse)
201
+ _XYZ_TO_RGB_MATRIX = np.array([
202
+ [ 3.2404542, -1.5371385, -0.4985314],
203
+ [-0.9692660, 1.8760108, 0.0415560],
204
+ [ 0.0556434, -0.2040259, 1.0572252]
205
+ ], dtype=np.float32)
206
+
207
+ # D65 illuminant reference white
208
+ _D65_WHITE = np.array([0.95047, 1.0, 1.08883], dtype=np.float32)
209
+
210
+
211
+ def rgb_to_lab(rgb: np.ndarray) -> np.ndarray:
212
+ """
213
+ Convert sRGB image to CIE L*a*b* color space.
214
+
215
+ Uses Numba-accelerated version if available.
216
+
217
+ Args:
218
+ rgb: RGB image (H, W, 3) float32 in [0, 1]
219
+
220
+ Returns:
221
+ Lab image (H, W, 3) where L is [0, 100], a/b are roughly [-128, 127]
222
+ """
223
+ if _HAVE_NUMBA:
224
+ rgb32 = np.ascontiguousarray(rgb.astype(np.float32))
225
+ xyz = rgb_to_xyz_numba(rgb32)
226
+ lab = xyz_to_lab_numba(xyz)
227
+ return lab
228
+
229
+ # Numpy fallback
230
+ rgb = np.asarray(rgb, dtype=np.float32)
231
+
232
+ # sRGB gamma linearization
233
+ linear = np.where(rgb > 0.04045,
234
+ np.power((rgb + 0.055) / 1.055, 2.4),
235
+ rgb / 12.92)
236
+
237
+ # RGB -> XYZ
238
+ xyz = np.einsum('ij,...j->...i', _RGB_TO_XYZ_MATRIX, linear)
239
+
240
+ # XYZ -> Lab
241
+ xyz_n = xyz / _D65_WHITE
242
+
243
+ def f(t):
244
+ return np.where(t > 0.008856,
245
+ np.power(t, 1/3),
246
+ 7.787 * t + 16/116)
247
+
248
+ fx, fy, fz = f(xyz_n[..., 0]), f(xyz_n[..., 1]), f(xyz_n[..., 2])
249
+
250
+ L = 116 * fy - 16
251
+ a = 500 * (fx - fy)
252
+ b = 200 * (fy - fz)
253
+
254
+ return np.stack([L, a, b], axis=-1).astype(np.float32)
255
+
256
+
257
+ def lab_to_rgb(lab: np.ndarray) -> np.ndarray:
258
+ """
259
+ Convert CIE L*a*b* image to sRGB color space.
260
+
261
+ Uses Numba-accelerated version if available.
262
+
263
+ Args:
264
+ lab: Lab image (H, W, 3)
265
+
266
+ Returns:
267
+ RGB image (H, W, 3) float32 in [0, 1]
268
+ """
269
+ if _HAVE_NUMBA:
270
+ lab32 = np.ascontiguousarray(lab.astype(np.float32))
271
+ xyz = lab_to_xyz_numba(lab32)
272
+ rgb = xyz_to_rgb_numba(xyz)
273
+ return np.clip(rgb, 0.0, 1.0)
274
+
275
+ # Numpy fallback
276
+ lab = np.asarray(lab, dtype=np.float32)
277
+ L, a, b = lab[..., 0], lab[..., 1], lab[..., 2]
278
+
279
+ # Lab -> XYZ
280
+ fy = (L + 16) / 116
281
+ fx = a / 500 + fy
282
+ fz = fy - b / 200
283
+
284
+ def f_inv(t):
285
+ return np.where(t > 0.206893,
286
+ np.power(t, 3),
287
+ (t - 16/116) / 7.787)
288
+
289
+ xyz = np.stack([f_inv(fx), f_inv(fy), f_inv(fz)], axis=-1) * _D65_WHITE
290
+
291
+ # XYZ -> linear RGB
292
+ linear = np.einsum('ij,...j->...i', _XYZ_TO_RGB_MATRIX, xyz)
293
+
294
+ # sRGB gamma correction
295
+ rgb = np.where(linear > 0.0031308,
296
+ 1.055 * np.power(np.maximum(linear, 0), 1/2.4) - 0.055,
297
+ 12.92 * linear)
298
+
299
+ return np.clip(rgb, 0.0, 1.0).astype(np.float32)
@@ -0,0 +1,185 @@
1
+ # pro/window_shelf.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtCore import Qt, QEvent, QTimer, QSize, QObject, QRect
4
+ from PyQt6.QtWidgets import QDockWidget, QListWidget, QListWidgetItem, QMdiSubWindow, QWidget
5
+ from PyQt6.QtGui import QIcon, QPixmap
6
+
7
+ WINDOW_SHELF_DEBUG = False # flip to True to log capture/restore details
8
+
9
+ def _dbg(owner, msg: str):
10
+ if not WINDOW_SHELF_DEBUG:
11
+ return
12
+ p = owner.parent()
13
+ if p and hasattr(p, "_log") and callable(getattr(p, "_log")):
14
+ p._log(f"[Shelf] {msg}")
15
+ else:
16
+ print(f"[Shelf] {msg}")
17
+
18
+ class WindowShelf(QDockWidget):
19
+ def __init__(self, parent=None):
20
+ super().__init__("Minimized Views", parent)
21
+
22
+ # PyQt6 dock area enum
23
+ self.setAllowedAreas(Qt.DockWidgetArea.AllDockWidgetAreas)
24
+
25
+ self.list = QListWidget(self)
26
+ self.list.setUniformItemSizes(False)
27
+ self.list.setIconSize(QSize(32, 32))
28
+ self.setWidget(self.list)
29
+
30
+ # map: QListWidgetItem -> subwindow
31
+ # use item id() as key because QListWidgetItem is unhashable
32
+ self._item2sub: dict[int, QMdiSubWindow] = {}
33
+ # map: subwindow -> {"geom": QRect, "max": bool}
34
+ self._saved_state: dict[QMdiSubWindow, dict] = {}
35
+
36
+ self.list.itemClicked.connect(self._restore)
37
+
38
+ # ---- public API used by the interceptor ----
39
+ def pre_capture_state(self, sub: QMdiSubWindow):
40
+ """Capture normal geometry/max state BEFORE we hide/minimize."""
41
+ if not sub:
42
+ return
43
+ was_max = sub.isMaximized()
44
+ # If was maximized, normalGeometry() holds the pre-max rect; otherwise use geometry()
45
+ g = sub.normalGeometry() if was_max else sub.geometry()
46
+ if not g.isValid():
47
+ g = sub.geometry()
48
+ self._saved_state[sub] = {"geom": QRect(g), "max": bool(was_max)}
49
+ _dbg(self, f"CAPTURE for '{sub.windowTitle()}': max={was_max}, geom={g}")
50
+
51
+ def add_entry(self, sub: QMdiSubWindow):
52
+ """Add a button to the shelf for `sub` (state must be pre-captured)."""
53
+ if sub is None or sub.widget() is None:
54
+ return
55
+
56
+ title = sub.windowTitle() or "Untitled"
57
+ # strip leading dot and Active prefix for the shelf display text only
58
+
59
+ # Remove any number of leading glyphs like ■ ● ◆ ▲ etc.
60
+ while len(title) >= 2 and title[1] == " " and title[0] in "■●◆▲▪▫•◼◻◾◽":
61
+ title = title[2:]
62
+
63
+ # Remove leading 'Active View: ' if present
64
+ if title.startswith("Active View: "):
65
+ title = title[len("Active View: "):]
66
+
67
+ # Best-effort thumbnail from the view's QLabel (if present)
68
+ icon = QIcon()
69
+ w = sub.widget()
70
+ pm = getattr(getattr(w, "label", None), "pixmap", lambda: None)()
71
+ if isinstance(pm, QPixmap) and not pm.isNull():
72
+ icon = QIcon(pm.scaled(
73
+ 64, 64,
74
+ Qt.AspectRatioMode.KeepAspectRatio,
75
+ Qt.TransformationMode.SmoothTransformation
76
+ ))
77
+ else:
78
+ icon = QIcon.fromTheme("image-x-generic")
79
+
80
+ item = QListWidgetItem(icon, title)
81
+ # store the subwindow via item data (so QListWidgetItem doesn't have to be a dict key)
82
+ item.setData(Qt.ItemDataRole.UserRole, sub)
83
+ self._item2sub[id(item)] = sub
84
+ self.list.addItem(item)
85
+ self.show()
86
+ _dbg(self, f"ADD entry for '{title}' (items={self.list.count()})")
87
+
88
+ # ---- restore flow ----
89
+ def _restore(self, item: QListWidgetItem):
90
+ sub = item.data(Qt.ItemDataRole.UserRole)
91
+ if not sub:
92
+ return
93
+
94
+ # Remove the shelf button first
95
+ row = self.list.row(item)
96
+ self.list.takeItem(row)
97
+ self._item2sub.pop(id(item), None)
98
+
99
+ st = self._saved_state.get(sub, None)
100
+ title = sub.windowTitle()
101
+ _dbg(self, f"RESTORE '{title}': have_state={bool(st)}")
102
+
103
+ try:
104
+ if st and st.get("max", False):
105
+ _dbg(self, f" → showMaximized()")
106
+ sub.showMaximized()
107
+ else:
108
+ # normal window → restore the exact rectangle
109
+ r = QRect()
110
+ if st and isinstance(st.get("geom"), QRect):
111
+ r = QRect(st["geom"])
112
+ _dbg(self, f" → target rect={r} (valid={r.isValid()})")
113
+
114
+ def apply_rect():
115
+ if r.isValid() and not sub.isMaximized():
116
+ # Apply both ways; some styles ignore one or the other during layout churn
117
+ sub.setWindowState(Qt.WindowState.WindowNoState)
118
+ sub.resize(r.size())
119
+ sub.move(r.topLeft())
120
+ sub.setGeometry(r)
121
+ _dbg(self, f" reapplied rect now={sub.geometry()}")
122
+
123
+ # Pre-apply (helps avoid the tiny default)
124
+ if r.isValid():
125
+ sub.setWindowState(Qt.WindowState.WindowNoState)
126
+ sub.setGeometry(r)
127
+ sub.resize(r.size())
128
+ sub.move(r.topLeft())
129
+
130
+ sub.showNormal()
131
+ # Once MDI has activated and re-laid out, re-apply a couple of times
132
+ QTimer.singleShot(0, apply_rect)
133
+ QTimer.singleShot(30, apply_rect)
134
+ QTimer.singleShot(120, apply_rect)
135
+
136
+ sub.raise_()
137
+ sub.activateWindow()
138
+ finally:
139
+ if self.list.count() == 0:
140
+ self.hide()
141
+
142
+ def remove_for_subwindow(self, sub):
143
+ if not sub:
144
+ return
145
+ for i in range(self.list.count()):
146
+ item = self.list.item(i)
147
+ if item.data(Qt.ItemDataRole.UserRole) is sub:
148
+ self._item2sub.pop(id(item), None)
149
+ self.list.takeItem(i)
150
+ break
151
+ self._saved_state.pop(sub, None) # ← also forget geometry for that sub
152
+ if self.list.count() == 0:
153
+ self.hide()
154
+
155
+ def clear_all(self):
156
+ """Remove all thumbnails and forget saved window states."""
157
+ try:
158
+ self.list.blockSignals(True)
159
+ self.list.clear()
160
+ finally:
161
+ self.list.blockSignals(False)
162
+ self._item2sub.clear()
163
+ self._saved_state.clear()
164
+ self.hide()
165
+
166
+ class MinimizeInterceptor(QObject):
167
+ """Redirect native minimize → shelf entry, capturing geometry BEFORE hiding."""
168
+ def __init__(self, shelf: WindowShelf, parent: QWidget | None = None):
169
+ super().__init__(parent)
170
+ self.shelf = shelf
171
+
172
+ def eventFilter(self, obj, ev):
173
+ if isinstance(obj, QMdiSubWindow) and ev.type() == QEvent.Type.WindowStateChange:
174
+ if obj.windowState() & Qt.WindowState.WindowMinimized:
175
+ # Capture state FIRST, then cancel minimize and hide.
176
+ self.shelf.pre_capture_state(obj)
177
+ QTimer.singleShot(0, lambda o=obj: self._redirect(o))
178
+ return True
179
+ return False
180
+
181
+ def _redirect(self, sub: QMdiSubWindow):
182
+ # Clear the minimized bit and hide, then add shelf entry
183
+ sub.setWindowState(sub.windowState() & ~Qt.WindowState.WindowMinimized)
184
+ sub.hide()
185
+ self.shelf.add_entry(sub)