setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -129,7 +129,7 @@ from PyQt6.QtGui import (QPixmap, QColor, QIcon, QKeySequence, QShortcut,
129
129
 
130
130
  # ----- QtCore -----
131
131
  from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QTimer, QSize, QSignalBlocker, QModelIndex, QThread, QUrl, QSettings, QEvent, QByteArray, QObject,
132
- QPropertyAnimation, QEasingCurve
132
+ QPropertyAnimation, QEasingCurve, QElapsedTimer
133
133
  )
134
134
 
135
135
  from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
@@ -168,7 +168,7 @@ except Exception:
168
168
 
169
169
 
170
170
 
171
-
171
+ _DEBUG_DND_DUP = False
172
172
 
173
173
 
174
174
 
@@ -187,14 +187,14 @@ from setiastro.saspro.resources import (
187
187
  platesolve_path, psf_path, supernova_path, starregistration_path,
188
188
  stacking_path, pedestal_icon_path, starspike_path, aperture_path,
189
189
  jwstpupil_path, signature_icon_path, livestacking_path, hrdiagram_path,
190
- convoicon_path, spcc_icon_path, sasp_data_path, exoicon_path, peeker_icon,
190
+ convoicon_path, spcc_icon_path, sasp_data_path, exoicon_path, peeker_icon,rotatearbitrary_path,
191
191
  dse_icon_path, astrobin_filters_csv_path, isophote_path, statstretch_path,
192
192
  starstretch_path, curves_path, disk_path, uhs_path, blink_path, ppp_path,
193
193
  nbtorgb_path, freqsep_path, contsub_path, halo_path, cosmic_path,
194
194
  satellite_path, imagecombine_path, wrench_path, eye_icon_path,multiscale_decomp_path,
195
195
  disk_icon_path, nuke_path, hubble_path, collage_path, annotated_path,
196
196
  colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path,
197
- wimi_path, linearfit_path, debayer_path, aberration_path,
197
+ wimi_path, linearfit_path, debayer_path, aberration_path, acv_icon_path,
198
198
  functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path,
199
199
  background_path, script_icon_path
200
200
  )
@@ -285,6 +285,144 @@ from setiastro.saspro.gui.mixins import (
285
285
  ThemeMixin, GeometryMixin, ViewMixin, HeaderMixin, MaskMixin, UpdateMixin
286
286
  )
287
287
 
288
+ import sys
289
+ import time
290
+ import threading
291
+ import traceback
292
+ from PyQt6.QtCore import QObject, QTimer
293
+
294
+ class UiStallDetector(QObject):
295
+ """
296
+ Detects UI stalls by watching QTimer tick drift.
297
+ On stall, prints stack traces for all threads using print().
298
+ (No faulthandler / no fileno() required.)
299
+ """
300
+
301
+ def __init__(self, parent=None, interval_ms: int = 50, threshold_ms: int = 300):
302
+ super().__init__(parent)
303
+ self.interval_ms = int(interval_ms)
304
+ self.threshold_ms = int(threshold_ms)
305
+ self._last = time.perf_counter()
306
+ self._stall_seq = 0
307
+
308
+ # cooldown state (instance-level)
309
+ self._last_dump_t = 0.0
310
+
311
+ self._timer = QTimer(self)
312
+ self._timer.setInterval(self.interval_ms)
313
+ self._timer.timeout.connect(self._tick)
314
+
315
+ def start(self):
316
+ self._last = time.perf_counter()
317
+ self._timer.start()
318
+
319
+ def stop(self):
320
+ self._timer.stop()
321
+
322
+ def _dump_all_threads_print(self):
323
+ now = time.perf_counter()
324
+ if now - self._last_dump_t < 2.0: # 2s cooldown
325
+ print("[UI STALL] dump skipped (cooldown)", flush=True)
326
+ return
327
+ self._last_dump_t = now
328
+
329
+ frames = sys._current_frames()
330
+ main_ident = threading.main_thread().ident
331
+
332
+ print("[UI STALL] ===== lightweight dump (all threads) =====", flush=True)
333
+
334
+ # Main thread: full stack
335
+ if main_ident in frames:
336
+ print("\n--- MainThread (full) ---", flush=True)
337
+ print("".join(traceback.format_stack(frames[main_ident])), flush=True)
338
+
339
+ # Other threads: only top-frame summary
340
+ for t in threading.enumerate():
341
+ if t.ident is None or t.ident == main_ident:
342
+ continue
343
+ f = frames.get(t.ident)
344
+ if not f:
345
+ continue
346
+ code = f.f_code
347
+ print(
348
+ f"--- Thread {t.ident} ({t.name}) top --- {code.co_filename}:{f.f_lineno} in {code.co_name}",
349
+ flush=True,
350
+ )
351
+
352
+ print("[UI STALL] ===== end lightweight dump =====", flush=True)
353
+
354
+ def _tick(self):
355
+ now = time.perf_counter()
356
+ elapsed_ms = (now - self._last) * 1000.0
357
+ self._last = now
358
+
359
+ late_ms = elapsed_ms - self.interval_ms
360
+ if late_ms >= self.threshold_ms:
361
+ print(f"[UI STALL] tick late by {late_ms:.0f} ms (elapsed={elapsed_ms:.0f} ms)", flush=True)
362
+ self._dump_all_threads_print()
363
+
364
+ def _strip_filename_ext(title: str) -> str:
365
+ t = (title or "").strip()
366
+ if not t:
367
+ return t
368
+ base, ext = os.path.splitext(t)
369
+ # treat as extension only if it looks like one: .fit .fits .tif .tiff .xisf etc
370
+ if ext and 1 <= len(ext) <= 10 and all(ch.isalnum() for ch in ext[1:]):
371
+ return base
372
+ return t
373
+
374
+
375
+
376
+ _DECOR_GLYPHS = "■●◆▲▪▫•◼◻◾◽🔗"
377
+
378
+ def normalize_doc_title(s: str) -> str:
379
+ s = (s or "").strip()
380
+
381
+ # remove our textual prefix too
382
+ if s.startswith("[LINK] "):
383
+ s = s[len("[LINK] "):].strip()
384
+
385
+ # strip common UI decorations if you already have this helper
386
+ try:
387
+ s = _strip_ui_decorations(s)
388
+ except Exception:
389
+ pass
390
+
391
+ # remove any leading decorator glyphs repeatedly: "🔗 ", "■ ", etc.
392
+ while len(s) >= 2 and s[0] in _DECOR_GLYPHS and s[1] == " ":
393
+ s = s[2:].lstrip()
394
+
395
+ # also remove any stray decorator glyphs that got embedded (rare but happens)
396
+ s = re.sub(rf"[{re.escape(_DECOR_GLYPHS)}]", "", s).strip()
397
+
398
+ return s
399
+
400
+ _VIEW_SUFFIX_RE = re.compile(r"\s+\[View\s+\d+\]\s*$")
401
+
402
+ def _normalize_title_for_compare(t: str) -> str:
403
+ t = (t or "").strip()
404
+ if not t:
405
+ return ""
406
+
407
+ # strip UI decorations (🔗, ■, etc)
408
+ try:
409
+ t = _strip_ui_decorations(t)
410
+ except Exception:
411
+ pass
412
+
413
+ # strip trailing "[View N]" if present
414
+ t = _VIEW_SUFFIX_RE.sub("", t).strip()
415
+
416
+ # strip filename-like extension
417
+ try:
418
+ t = _strip_filename_ext(t)
419
+ except Exception:
420
+ # fallback: only strip if it looks like an ext
421
+ base, ext = os.path.splitext(t)
422
+ if ext and len(ext) <= 10:
423
+ t = base
424
+
425
+ return t.strip()
288
426
 
289
427
  class AstroSuiteProMainWindow(
290
428
  DockMixin, MenuMixin, ToolbarMixin, FileMixin,
@@ -299,7 +437,8 @@ class AstroSuiteProMainWindow(
299
437
  # Prevent white flash: start strictly transparent and force dark bg
300
438
  self.setWindowOpacity(0.0)
301
439
  self.setStyleSheet("QMainWindow { background-color: #0F0F19; }")
302
-
440
+ #self._stall = UiStallDetector(self, interval_ms=50, threshold_ms=250)
441
+ #self._stall.start()
303
442
  # --- Usage Stats ---
304
443
  self._session_start_time = time.time()
305
444
  self._stats_timer = QTimer(self)
@@ -310,7 +449,7 @@ class AstroSuiteProMainWindow(
310
449
  from setiastro.saspro.window_shelf import WindowShelf, MinimizeInterceptor
311
450
  from setiastro.saspro.imageops.mdi_snap import MdiSnapController
312
451
  from setiastro.saspro.ops.scripts import ScriptManager
313
- self._version = "1.6.1"
452
+ self._version = version
314
453
  self._build_timestamp = build_timestamp
315
454
  self.setWindowTitle(f"Seti Astro Suite Pro v{self._version}")
316
455
  self.resize(1400, 900)
@@ -467,26 +606,23 @@ class AstroSuiteProMainWindow(
467
606
  self.mdi.linkViewDropped.connect(self._on_linkview_drop)
468
607
 
469
608
  self.doc_manager.set_mdi_area(self.mdi)
470
-
609
+ # Coalesce undo/redo label refreshes
610
+ self._undo_redo_refresh_pending = False
611
+ self._undo_redo_refresh_timer = QTimer(self)
612
+ self._undo_redo_refresh_timer.setSingleShot(True)
613
+ self._undo_redo_refresh_timer.timeout.connect(self._do_undo_redo_label_refresh)
471
614
  # Keep the toolbar in sync whenever anything relevant changes
472
- self.doc_manager.documentAdded.connect(lambda *_: self.update_undo_redo_action_labels())
473
- self.doc_manager.documentRemoved.connect(lambda *_: self.update_undo_redo_action_labels())
474
- self.doc_manager.imageRegionUpdated.connect(lambda *_: self.update_undo_redo_action_labels())
475
- self.doc_manager.previewRepaintRequested.connect(lambda *_: self.update_undo_redo_action_labels())
476
-
477
- # Also refresh when the active subwindow changes
478
- try:
479
- self.mdi.subWindowActivated.connect(lambda *_: self.update_undo_redo_action_labels())
480
- except Exception:
481
- pass
482
-
483
- try:
484
- QApplication.instance().focusChanged.connect(
485
- lambda *_: QTimer.singleShot(0, self.update_undo_redo_action_labels)
486
- )
487
- except Exception:
488
- pass
489
-
615
+ self.doc_manager.documentAdded.connect(lambda *_: self._schedule_undo_redo_label_refresh())
616
+ self.doc_manager.documentRemoved.connect(lambda *_: self._schedule_undo_redo_label_refresh())
617
+ self.doc_manager.imageRegionUpdated.connect(lambda *_: self._schedule_undo_redo_label_refresh())
618
+ self.doc_manager.previewRepaintRequested.connect(lambda *_: self._schedule_undo_redo_label_refresh())
619
+ self.mdi.subWindowActivated.connect(lambda *_: self._schedule_undo_redo_label_refresh())
620
+
621
+ # optional: keep, but schedule (or remove entirely)
622
+ #try:
623
+ # QApplication.instance().focusChanged.connect(lambda *_: self._schedule_undo_redo_label_refresh())
624
+ #except Exception:
625
+ # pass
490
626
  self.shortcuts.load_shortcuts()
491
627
  self._ensure_persistent_names()
492
628
  self._restore_window_placement()
@@ -570,6 +706,22 @@ class AstroSuiteProMainWindow(
570
706
 
571
707
  # _init_log_dock, _hook_stdout_stderr, and _append_log_text are now in DockMixin
572
708
 
709
+ def _schedule_undo_redo_label_refresh(self):
710
+ # Coalesce many triggers into one UI update
711
+ if getattr(self, "_undo_redo_refresh_pending", False):
712
+ return
713
+ self._undo_redo_refresh_pending = True
714
+ # 0ms is fine *if* it’s a real attribute timer (not a local)
715
+ self._undo_redo_refresh_timer.start(0)
716
+
717
+ def _do_undo_redo_label_refresh(self):
718
+ self._undo_redo_refresh_pending = False
719
+ try:
720
+ self.update_undo_redo_action_labels()
721
+ except Exception:
722
+ pass
723
+
724
+
573
725
  def _rebuild_menus_for_language(self):
574
726
  """Rebuild menus after language change to apply new translations."""
575
727
  try:
@@ -608,7 +760,7 @@ class AstroSuiteProMainWindow(
608
760
  doc.changed.connect(self.update_undo_redo_action_labels)
609
761
  except Exception:
610
762
  pass
611
- self.update_undo_redo_action_labels()
763
+ self._schedule_undo_redo_label_refresh()
612
764
 
613
765
  def _promote_roi_preview_to_real_doc(self, st: dict, preview_doc) -> None:
614
766
  """
@@ -843,7 +995,18 @@ class AstroSuiteProMainWindow(
843
995
  dm = self.doc_manager
844
996
  doc = None
845
997
 
998
+ if _DEBUG_DND_DUP:
999
+ import json
1000
+ print("\n[DNDDBG:DROP_ENTER] raw st dict:")
1001
+ try:
1002
+ # st is already a dict here
1003
+ for k in sorted(st.keys()):
1004
+ print(f" {k}: {st.get(k)!r}")
1005
+ except Exception as e:
1006
+ print("[DNDDBG:DROP_ENTER] failed printing st:", e)
846
1007
 
1008
+ # sanity: show which fields are present
1009
+ print("[DNDDBG:DROP_ENTER] has source_view_title?", "source_view_title" in st)
847
1010
 
848
1011
  # Prefer *stable* identifiers over the proxy pointer
849
1012
  uid = st.get("doc_uid")
@@ -946,7 +1109,21 @@ class AstroSuiteProMainWindow(
946
1109
  print("[VIEWSTATE_DROP] EXIT (no doc)")
947
1110
  return
948
1111
 
949
-
1112
+ if _DEBUG_DND_DUP:
1113
+ try:
1114
+ dname = doc.display_name() if hasattr(doc, "display_name") else None
1115
+ except Exception:
1116
+ dname = None
1117
+ try:
1118
+ meta = getattr(doc, "metadata", {}) or {}
1119
+ except Exception:
1120
+ meta = {}
1121
+ print("\n[DNDDBG:DOC_RESOLVED]")
1122
+ print(" doc_obj:", doc, "type:", type(doc).__name__, "id:", id(doc))
1123
+ print(" doc.uid:", getattr(doc, "uid", None))
1124
+ print(" doc.display_name():", dname)
1125
+ print(" meta.display_name:", meta.get("display_name"))
1126
+ print(" meta.file_path:", meta.get("file_path"))
950
1127
 
951
1128
  # ----------------------------------------
952
1129
  # 4) Peek at metadata to see if this is a
@@ -1037,34 +1214,35 @@ class AstroSuiteProMainWindow(
1037
1214
  # copy the view transform.
1038
1215
  # ----------------------------------------
1039
1216
  if force_new:
1040
- # We're here only if:
1041
- # - it's NOT a preview (normal full or promoted ROI), or
1042
- # - ROI promotion didn't apply and we fell through.
1043
1217
  base_doc = doc
1044
1218
 
1045
- # 1) Duplicate the underlying document
1046
- try:
1047
- base_name = ""
1219
+ # 1) Prefer the dragged view's title
1220
+ base_name = (st.get("source_view_title") or "").strip()
1221
+
1222
+ # 2) Fallback to document display name
1223
+ if not base_name:
1048
1224
  try:
1049
1225
  base_name = base_doc.display_name() or "Untitled"
1050
1226
  except Exception:
1051
1227
  base_name = "Untitled"
1052
1228
 
1053
- try:
1054
- base_name = _strip_ui_decorations(base_name)
1055
- except Exception:
1056
- # minimal fallback: remove known glyph prefixes and "Active View: "
1057
- while len(base_name) >= 2 and base_name[1] == " " and base_name[0] in "â- â--â--†â-²â-ªâ-«â€¢â--¼â--»â--¾â--½":
1058
- base_name = base_name[2:]
1059
- if base_name.startswith("Active View: "):
1060
- base_name = base_name[len("Active View: "):]
1061
-
1062
- new_doc = self.docman.duplicate_document(
1063
- base_doc, new_name=f"{base_name}_duplicate"
1064
- )
1065
- except Exception as e:
1066
- print("[Main] viewstate_drop: duplicate_document failed, falling back to original doc:", e)
1067
- new_doc = base_doc # worst-case: still just reuse
1229
+ # 3) Clean it (strip glyphs / "Active View" / etc.)
1230
+ try:
1231
+ base_name = _strip_ui_decorations(base_name)
1232
+ except Exception:
1233
+ if base_name.startswith("Active View: "):
1234
+ base_name = base_name[len("Active View: "):]
1235
+
1236
+ if _DEBUG_DND_DUP:
1237
+ print("\n[DNDDBG:NAME_COMPUTE]")
1238
+ print(" st.source_view_title:", (st.get("source_view_title") or "").strip())
1239
+ print(" base_doc.display_name():", (base_doc.display_name() if hasattr(base_doc,"display_name") else None))
1240
+ print(" base_name(after fallbacks/strip):", base_name)
1241
+ print(" new_name passed:", f"{base_name}_duplicate")
1242
+
1243
+ new_doc = self.docman.duplicate_document(
1244
+ base_doc, new_name=f"{base_name}_duplicate"
1245
+ )
1068
1246
 
1069
1247
  # 2) Let doc_manager's documentAdded handler create the subwindow.
1070
1248
  # We just wait for it to show up and then apply the view state.
@@ -1190,14 +1368,6 @@ class AstroSuiteProMainWindow(
1190
1368
  return False
1191
1369
 
1192
1370
  def _on_document_added(self, doc):
1193
- # Helpful debug:
1194
- try:
1195
- is_table = (getattr(doc, "metadata", {}).get("doc_type") == "table") or \
1196
- (hasattr(doc, "rows") and hasattr(doc, "headers"))
1197
- self._log(f"[documentAdded] {type(doc).__name__} table={is_table} name={getattr(doc, 'display_name', lambda:'?')()}")
1198
- except Exception:
1199
- pass
1200
-
1201
1371
  self._spawn_subwindow_for(doc)
1202
1372
 
1203
1373
  # --- UI scaffolding ---
@@ -1206,11 +1376,11 @@ class AstroSuiteProMainWindow(
1206
1376
  global_pos = lw.viewport().mapToGlobal(pos)
1207
1377
 
1208
1378
  menu = QMenu(lw)
1209
- act_copy_selected = menu.addAction("Copy Selected")
1210
- act_copy_all = menu.addAction("Copy All")
1379
+ act_copy_selected = menu.addAction(self.tr("Copy Selected"))
1380
+ act_copy_all = menu.addAction(self.tr("Copy All"))
1211
1381
  menu.addSeparator()
1212
- act_select_all = menu.addAction("Select All Lines")
1213
- act_clear = menu.addAction("Clear Console")
1382
+ act_select_all = menu.addAction(self.tr("Select All Lines"))
1383
+ act_clear = menu.addAction(self.tr("Clear Console"))
1214
1384
 
1215
1385
  action = menu.exec(global_pos)
1216
1386
  if action is None:
@@ -1804,7 +1974,7 @@ class AstroSuiteProMainWindow(
1804
1974
 
1805
1975
  show_view_bundles(self)
1806
1976
  except Exception as e:
1807
- QMessageBox.warning(self, "View Bundles", f"Open failed:\n{e}")
1977
+ QMessageBox.warning(self, self.tr("View Bundles"), f"Open failed:\n{e}")
1808
1978
 
1809
1979
  def _open_function_bundles(self):
1810
1980
  from setiastro.saspro.function_bundle import show_function_bundles
@@ -1812,7 +1982,7 @@ class AstroSuiteProMainWindow(
1812
1982
 
1813
1983
  show_function_bundles(self)
1814
1984
  except Exception as e:
1815
- QMessageBox.warning(self, "Function Bundles", f"Open failed:\n{e}")
1985
+ QMessageBox.warning(self, self.tr("Function Bundles"), f"Open failed:\n{e}")
1816
1986
 
1817
1987
  def _open_scripts_folder(self):
1818
1988
  if hasattr(self, "scriptman"):
@@ -1874,43 +2044,43 @@ class AstroSuiteProMainWindow(
1874
2044
  # Manual list (extend anytime). Format: (Gesture, Context, Effect)
1875
2045
  rows = [
1876
2046
  # Command search
1877
- ("A", "Display Stretch", "Toggle Display Auto-Stretch"),
1878
- ("Ctrl+I", "Invert", "Invert the Image"),
1879
- ("Ctrl+Shift+P", "Command Search", "Focus the command search bar; Enter runs first match"),
2047
+ ("A", "Display Stretch", self.tr("Toggle Display Auto-Stretch")),
2048
+ ("Ctrl+I", "Invert", self.tr("Invert the Image")),
2049
+ ("Ctrl+Shift+P", "Command Search", self.tr("Focus the command search bar; Enter runs first match")),
1880
2050
 
1881
2051
  # View Icon
1882
- ("Drag view -> Off to Canvas", "View", "Duplicate Image"),
1883
- ("Drag view -> On to Other Image", "View", "Copy Zoom and Pan"),
1884
- ("Shift+Drag -> On to Other Image", "View", "Apply that image to the other as a mask"),
1885
- ("Ctrl+Drag -> On to Other Image", "View", "Copy Astrometric Solution"),
2052
+ ("Drag view -> Off to Canvas", "View", self.tr("Duplicate Image")),
2053
+ ("Drag view -> On to Other Image", "View", self.tr("Copy Zoom and Pan")),
2054
+ ("Shift+Drag -> On to Other Image", "View", self.tr("Apply that image to the other as a mask")),
2055
+ ("Ctrl+Drag -> On to Other Image", "View", self.tr("Copy Astrometric Solution")),
1886
2056
 
1887
2057
  # View zoom
1888
- ("Ctrl+1", "View", "Zoom to 100% (1:1)"),
1889
- ("Ctrl+0", "View", "Fit image to current window"),
1890
- ("Ctrl++", "View", "Zoom In"),
1891
- ("Ctrl+-", "View", "Zoom Out"),
2058
+ ("Ctrl+1", "View", self.tr("Zoom to 100% (1:1)")),
2059
+ ("Ctrl+0", "View", self.tr("Fit image to current window")),
2060
+ ("Ctrl++", "View", self.tr("Zoom In")),
2061
+ ("Ctrl+-", "View", self.tr("Zoom Out")),
1892
2062
 
1893
2063
  # Window switching
1894
- ("Ctrl+PgDown", "MDI", "Switch to previously active view"),
1895
- ("Ctrl+PgUp", "MDI", "Switch to next active view"),
2064
+ ("Ctrl+PgDown", "MDI", self.tr("Switch to previously active view")),
2065
+ ("Ctrl+PgUp", "MDI", self.tr("Switch to next active view")),
1896
2066
 
1897
2067
  # Shortcuts canvas + buttons
1898
- ("Alt+Drag (toolbar button)", "Toolbar", "Create a desktop shortcut for that action"),
1899
- ("Alt+Drag (shortcut button -> view)", "Shortcuts", "Headless apply the shortcut's command/preset to a view"),
1900
- ("Ctrl/Shift+Click", "Shortcuts", "Multi-select shortcut buttons"),
1901
- ("Drag (selection)", "Shortcuts", "Move selected shortcut buttons"),
1902
- ("Delete / Backspace", "Shortcuts", "Delete selected shortcut buttons"),
1903
- ("Ctrl+A", "Shortcuts", "Select all shortcut buttons"),
1904
- ("Double-click empty area", "MDI background", "Open files dialog"),
2068
+ ("Alt+Drag (toolbar button)", "Toolbar", self.tr("Create a desktop shortcut for that action")),
2069
+ ("Alt+Drag (shortcut button -> view)", "Shortcuts", self.tr("Headless apply the shortcut's command/preset to a view")),
2070
+ ("Ctrl/Shift+Click", "Shortcuts", self.tr("Multi-select shortcut buttons")),
2071
+ ("Drag (selection)", "Shortcuts", self.tr("Move selected shortcut buttons")),
2072
+ ("Delete / Backspace", "Shortcuts", self.tr("Delete selected shortcut buttons")),
2073
+ ("Ctrl+A", "Shortcuts", self.tr("Select all shortcut buttons")),
2074
+ ("Double-click empty area", "MDI background", self.tr("Open files dialog")),
1905
2075
 
1906
2076
  # Layers dock
1907
- ("Drag view -> Layers list", "Layers", "Add dragged view as a new layer (on top)"),
1908
- ("Shift+Drag mask -> Layers list", "Layers", "Attach dragged image as mask to the selected layer"),
2077
+ ("Drag view -> Layers list", "Layers", self.tr("Add dragged view as a new layer (on top)")),
2078
+ ("Shift+Drag mask -> Layers list", "Layers", self.tr("Attach dragged image as mask to the selected layer")),
1909
2079
 
1910
2080
  # Crop tool
1911
- ("Click-drag", "Crop Tool", "Draw a crop rectangle"),
1912
- ("Drag corner handles", "Crop Tool", "Resize crop rectangle"),
1913
- ("Shift+Drag on box", "Crop Tool", "Rotate crop rectangle"),
2081
+ ("Click-drag", "Crop Tool", self.tr("Draw a crop rectangle")),
2082
+ ("Drag corner handles", "Crop Tool", self.tr("Resize crop rectangle")),
2083
+ ("Shift+Drag on box", "Crop Tool", self.tr("Rotate crop rectangle")),
1914
2084
  ]
1915
2085
  return rows
1916
2086
 
@@ -2027,7 +2197,7 @@ class AstroSuiteProMainWindow(
2027
2197
  if getattr(self, "doc_manager", None) and self.doc_manager._docs:
2028
2198
  if not self._confirm_discard(
2029
2199
  title=title,
2030
- msg=(
2200
+ msg=self.tr(
2031
2201
  "Loading a project will close current views and replace desktop shortcuts.\n"
2032
2202
  "Continue?"
2033
2203
  ),
@@ -2490,10 +2660,27 @@ class AstroSuiteProMainWindow(
2490
2660
  return f"{name}{dims}"
2491
2661
 
2492
2662
  def _update_explorer_item_for_doc(self, doc):
2493
- for i in range(self.explorer.count()):
2494
- it = self.explorer.item(i)
2495
- if it.data(Qt.ItemDataRole.UserRole) is doc:
2496
- it.setText(self._format_explorer_title(doc))
2663
+ # Delegate to DockMixin implementation if present
2664
+ try:
2665
+ return super()._update_explorer_item_for_doc(doc)
2666
+ except Exception:
2667
+ pass
2668
+
2669
+ # Fallback: tree-safe implementation
2670
+ if not hasattr(self, "explorer") or self.explorer is None:
2671
+ return
2672
+ try:
2673
+ n = self.explorer.topLevelItemCount()
2674
+ except Exception:
2675
+ return
2676
+
2677
+ for i in range(n):
2678
+ it = self.explorer.topLevelItem(i)
2679
+ if it.data(0, Qt.ItemDataRole.UserRole) is doc:
2680
+ try:
2681
+ self._refresh_explorer_row(it, doc)
2682
+ except Exception:
2683
+ pass
2497
2684
  return
2498
2685
  #-----------FUNCTIONS----------------
2499
2686
 
@@ -3002,17 +3189,17 @@ class AstroSuiteProMainWindow(
3002
3189
 
3003
3190
  self.convo_window.show()
3004
3191
 
3005
-
3006
-
3007
3192
  def _apply_extract_luminance_preset_to_doc(self, doc, preset=None):
3008
-
3009
3193
  from PyQt6.QtWidgets import QMessageBox
3010
- from setiastro.saspro.luminancerecombine import compute_luminance, _LUMA_REC709, _LUMA_REC601, _LUMA_REC2020
3011
- from setiastro.saspro.headless_utils import normalize_headless_main, unwrap_docproxy
3194
+ from setiastro.saspro.luminancerecombine import (
3195
+ compute_luminance,
3196
+ resolve_luma_profile_weights,
3197
+ )
3198
+ from setiastro.saspro.headless_utils import unwrap_docproxy
3199
+ import numpy as np
3012
3200
 
3013
3201
  doc = unwrap_docproxy(doc)
3014
3202
  p = dict(preset or {})
3015
- mode = (p.get("mode") or "rec709").lower()
3016
3203
 
3017
3204
  if doc is None or getattr(doc, "image", None) is None:
3018
3205
  QMessageBox.information(self, "Extract Luminance", "No target image.")
@@ -3020,52 +3207,43 @@ class AstroSuiteProMainWindow(
3020
3207
 
3021
3208
  img = np.asarray(doc.image)
3022
3209
 
3023
- # pick weights
3024
- if mode == "rec601":
3025
- w = _LUMA_REC601
3026
- elif mode == "rec2020":
3027
- w = _LUMA_REC2020
3028
- elif mode == "max":
3029
- w = None
3030
- else:
3031
- w = _LUMA_REC709
3210
+ mode = str(p.get("mode", "rec709")).strip()
3211
+ resolved_method, w, profile_name = resolve_luma_profile_weights(mode)
3032
3212
 
3033
- L = compute_luminance(img, method=mode, weights=w)
3213
+ L = compute_luminance(img, method=resolved_method, weights=w)
3034
3214
 
3035
3215
  dm = getattr(self, "doc_manager", None)
3036
3216
  if dm is None:
3037
- # headless fallback: just overwrite active doc
3038
3217
  doc.apply_edit(L.astype(np.float32), step_name="Extract Luminance")
3039
3218
  return
3040
3219
 
3041
- # normal behavior: create a new mono document
3220
+ meta = {
3221
+ "step_name": "Extract Luminance",
3222
+ "luma_method": resolved_method,
3223
+ }
3224
+ if w is not None:
3225
+ meta["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
3226
+ if profile_name:
3227
+ meta["luma_profile"] = str(profile_name)
3228
+
3042
3229
  try:
3230
+ suffix = f"{profile_name}" if profile_name else resolved_method
3043
3231
  new_doc = dm.create_document_from_array(
3044
3232
  L.astype(np.float32),
3045
- name=f"{doc.display_name()} -- Luminance ({mode})",
3233
+ name=f"{doc.display_name()} -- Luminance ({suffix})",
3046
3234
  is_mono=True,
3047
- metadata={"step_name":"Extract Luminance", "luma_method":mode}
3235
+ metadata=meta,
3048
3236
  )
3049
3237
  dm.add_document(new_doc)
3050
3238
  except Exception:
3051
- # safe fallback
3052
3239
  doc.apply_edit(L.astype(np.float32), step_name="Extract Luminance")
3053
3240
 
3054
-
3055
3241
  def _extract_luminance(self, doc=None, preset: dict | None = None):
3056
- from setiastro.saspro.luminancerecombine import _LUMA_REC709, _LUMA_REC601, _LUMA_REC2020
3057
- """
3058
- If doc is None, uses the active subwindow's document.
3059
- Otherwise, run on the provided doc (for drag-and-drop to a specific view).
3060
- Creates a new mono document (float32, [0..1]) and spawns a subwindow.
3061
-
3062
- Preset schema:
3063
- {
3064
- "mode": "rec709" | "rec601" | "rec2020" | "max" | "snr" | "equal" | "median",
3065
- # aliases accepted: method, luma_method, nb_max -> "max", snr_unequal -> "snr"
3066
- }
3067
- """
3068
- # 1) resolve source document
3242
+ from PyQt6.QtWidgets import QMessageBox
3243
+ from PyQt6.QtGui import QIcon
3244
+ from setiastro.saspro.luminancerecombine import compute_luminance, resolve_luma_profile_weights
3245
+
3246
+
3069
3247
  sw = None
3070
3248
  if doc is None:
3071
3249
  sw = self.mdi.activeSubWindow()
@@ -3084,70 +3262,19 @@ class AstroSuiteProMainWindow(
3084
3262
  QMessageBox.information(self, "Extract Luminance", "Luminance extraction requires an RGB image.")
3085
3263
  return
3086
3264
 
3087
- # 2) normalize to [0,1] float32
3088
- a = img.astype(np.float32, copy=False)
3089
- if a.size:
3090
- m = float(np.nanmax(a))
3091
- if np.isfinite(m) and m > 1.0:
3092
- a = a / m
3093
- a = np.clip(a, 0.0, 1.0)
3094
-
3095
- # 3) choose luminance method
3096
3265
  p = dict(preset or {})
3097
- method = str(
3266
+ mode = str(
3098
3267
  p.get("mode",
3099
3268
  p.get("method",
3100
3269
  p.get("luma_method",
3101
3270
  getattr(self, "luma_method", "rec709"))))
3102
- ).strip().lower()
3103
-
3104
- # aliases
3105
- alias = {
3106
- "rec.709": "rec709",
3107
- "rec-709": "rec709",
3108
- "rgb": "rec709",
3109
- "k": "rec709",
3110
- "rec.601": "rec601",
3111
- "rec-601": "rec601",
3112
- "rec.2020": "rec2020",
3113
- "rec-2020": "rec2020",
3114
- "nb_max": "max",
3115
- "narrowband": "max",
3116
- "snr_unequal": "snr",
3117
- "unequal_noise": "snr",
3118
- }
3119
- method = alias.get(method, method)
3120
-
3121
- # 4) compute luminance per selected method
3122
- luma_weights = None
3123
- if method == "rec601":
3124
- luma_weights = _LUMA_REC601
3125
- y = np.tensordot(a, _LUMA_REC601, axes=([2],[0]))
3126
- elif method == "rec2020":
3127
- luma_weights = _LUMA_REC2020
3128
- y = np.tensordot(a, _LUMA_REC2020, axes=([2],[0]))
3129
- elif method == "max":
3130
- y = a.max(axis=2)
3131
- elif method == "median":
3132
- y = np.median(a, axis=2)
3133
- elif method == "equal":
3134
- luma_weights = np.array([1/3, 1/3, 1/3], dtype=np.float32)
3135
- y = a.mean(axis=2)
3136
- elif method == "snr":
3137
- from setiastro.saspro.luminancerecombine import _estimate_noise_sigma_per_channel
3138
- sigma = _estimate_noise_sigma_per_channel(a)
3139
- w = 1.0 / (sigma[:3]**2 + 1e-12)
3140
- w = w / w.sum()
3141
- luma_weights = w.astype(np.float32)
3142
- y = np.tensordot(a[..., :3], luma_weights, axes=([2],[0]))
3143
- else: # "rec709" default
3144
- method = "rec709"
3145
- luma_weights = _LUMA_REC709
3146
- y = np.tensordot(a, _LUMA_REC709, axes=([2],[0]))
3147
-
3148
- y = np.clip(y.astype(np.float32, copy=False), 0.0, 1.0)
3149
-
3150
- # 5) metadata & title
3271
+ ).strip()
3272
+
3273
+ resolved_method, w, profile_name = resolve_luma_profile_weights(mode)
3274
+
3275
+ y = compute_luminance(img, method=resolved_method, weights=w)
3276
+
3277
+ # ---- metadata & title ----
3151
3278
  base_meta = {}
3152
3279
  try:
3153
3280
  base_meta = dict(getattr(doc, "metadata", {}) or {})
@@ -3159,13 +3286,16 @@ class AstroSuiteProMainWindow(
3159
3286
  "source": "ExtractLuminance",
3160
3287
  "is_mono": True,
3161
3288
  "bit_depth": "32f",
3162
- "luma_method": method,
3289
+ "luma_method": resolved_method,
3163
3290
  }
3164
- if luma_weights is not None:
3165
- meta["luma_weights"] = np.asarray(luma_weights, dtype=np.float32).tolist()
3291
+ if w is not None:
3292
+ meta["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
3293
+ if profile_name:
3294
+ meta["luma_profile"] = str(profile_name)
3166
3295
 
3167
3296
  base_title = sw.windowTitle() if sw else (getattr(doc, "title", getattr(doc, "name", "")) or "Untitled")
3168
- title = f"{base_title} -- Luminance"
3297
+ suffix = f"{profile_name}" if profile_name else resolved_method
3298
+ title = f"{base_title} -- Luminance ({suffix})"
3169
3299
 
3170
3300
  dm = getattr(self, "docman", None)
3171
3301
  if dm is None:
@@ -3193,19 +3323,19 @@ class AstroSuiteProMainWindow(
3193
3323
  except Exception:
3194
3324
  pass
3195
3325
 
3196
- # ðŸ" Remember for Replay (optional but consistent)
3197
3326
  try:
3198
3327
  remember = getattr(self, "remember_last_headless_command", None) or getattr(self, "_remember_last_headless_command", None)
3199
3328
  if callable(remember):
3200
- remember("extract_luminance", {"mode": method}, description="Extract Luminance")
3329
+ remember("extract_luminance", {"mode": mode}, description="Extract Luminance")
3201
3330
  except Exception:
3202
3331
  pass
3203
3332
 
3204
3333
  if hasattr(self, "_log"):
3205
- self._log(f"Extract Luminance ({method}) -> new mono document created.")
3334
+ self._log(f"Extract Luminance ({suffix}) -> new mono document created.")
3206
3335
 
3207
3336
  return new_doc
3208
3337
 
3338
+
3209
3339
  def _subwindow_docs(self):
3210
3340
  docs = []
3211
3341
  for sw in self.mdi.subWindowList():
@@ -4456,6 +4586,48 @@ class AstroSuiteProMainWindow(
4456
4586
  dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
4457
4587
  dlg.show()
4458
4588
 
4589
+ def _open_acv_exporter(self):
4590
+ from setiastro.saspro.acv_exporter import AstroCatalogueViewerExporterDialog
4591
+
4592
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
4593
+ if dm is None:
4594
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "No document manager available.")
4595
+ return
4596
+
4597
+ sw = self.mdi.activeSubWindow()
4598
+ if not sw:
4599
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "Open an image first.")
4600
+ return
4601
+
4602
+ view = sw.widget()
4603
+ active_doc = None
4604
+
4605
+ # Prefer ROI-aware resolution
4606
+ try:
4607
+ if hasattr(dm, "get_document_for_view"):
4608
+ active_doc = dm.get_document_for_view(view)
4609
+ except Exception:
4610
+ active_doc = None
4611
+
4612
+ # Fallback
4613
+ if active_doc is None:
4614
+ try:
4615
+ active_doc = getattr(view, "document", None)
4616
+ except Exception:
4617
+ active_doc = None
4618
+
4619
+ if active_doc is None or getattr(active_doc, "image", None) is None:
4620
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "No active image.")
4621
+ return
4622
+
4623
+ dlg = AstroCatalogueViewerExporterDialog(self, dm, active_doc)
4624
+ try:
4625
+ dlg.setWindowIcon(QIcon(acv_icon_path))
4626
+ except Exception:
4627
+ pass
4628
+ dlg.show()
4629
+
4630
+
4459
4631
  def _open_linear_fit(self):
4460
4632
  from setiastro.saspro.linear_fit import LinearFitDialog
4461
4633
  dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
@@ -4572,12 +4744,6 @@ class AstroSuiteProMainWindow(
4572
4744
  if max_len and len(hist) > max_len:
4573
4745
  del hist[:-max_len]
4574
4746
 
4575
- # Logging as before
4576
- try:
4577
- self._log(f"[Replay] Last action stored: {desc} (command_id={command_id})")
4578
- except Exception:
4579
- print(f"[Replay] Last action stored: {desc} (command_id={command_id})")
4580
-
4581
4747
 
4582
4748
 
4583
4749
  def _remember_last_headless_command(self, command_id: str, preset: dict | None = None, description: str = ""):
@@ -4633,17 +4799,6 @@ class AstroSuiteProMainWindow(
4633
4799
  """
4634
4800
  payload = getattr(self, "_last_headless_command", None)
4635
4801
 
4636
- # DEBUG
4637
- try:
4638
- self._log(
4639
- f"[Replay] replay_last_action_on_subwindow: payload={bool(payload)}, "
4640
- f"target_sw={id(target_sw) if target_sw else None}"
4641
- )
4642
- except Exception:
4643
- print(
4644
- f"[Replay] replay_last_action_on_subwindow: payload={bool(payload)}, "
4645
- f"target_sw={id(target_sw) if target_sw else None}"
4646
- )
4647
4802
 
4648
4803
  if not payload:
4649
4804
  QMessageBox.information(
@@ -4675,17 +4830,6 @@ class AstroSuiteProMainWindow(
4675
4830
  """
4676
4831
  payload = getattr(self, "_last_headless_command", None) or {}
4677
4832
 
4678
- # DEBUG
4679
- try:
4680
- self._log(
4681
- f"[Replay] replay_last_action_on_base: payload={bool(payload)}, "
4682
- f"target_sw={id(target_sw) if target_sw else None}"
4683
- )
4684
- except Exception:
4685
- print(
4686
- f"[Replay] replay_last_action_on_base: payload={bool(payload)}, "
4687
- f"target_sw={id(target_sw) if target_sw else None}"
4688
- )
4689
4833
 
4690
4834
  if not payload:
4691
4835
  QMessageBox.information(
@@ -4711,17 +4855,6 @@ class AstroSuiteProMainWindow(
4711
4855
  QMessageBox.information(self, "Replay Last Action", "No base image to apply the action to.")
4712
4856
  return
4713
4857
 
4714
- # Small debug about which doc we're hitting
4715
- try:
4716
- view = target_sw.widget()
4717
- cur_doc = getattr(view, "document", None)
4718
- self._log(
4719
- f"[Replay] base_doc id={id(base_doc)}, "
4720
- f"view.document id={id(cur_doc)}, "
4721
- f"same={base_doc is cur_doc}"
4722
- )
4723
- except Exception:
4724
- pass
4725
4858
 
4726
4859
  # ---- Extract cid + preset from payload (support both old + new schemas) ----
4727
4860
  cid_raw = payload.get("command_id")
@@ -5542,6 +5675,10 @@ class AstroSuiteProMainWindow(
5542
5675
  "rotate_180": "geom_rotate_180",
5543
5676
  "geom_rotate_180": "geom_rotate_180",
5544
5677
 
5678
+ "rotate_any": "geom_rotate_any",
5679
+ "rotate_arbitrary": "geom_rotate_any",
5680
+ "geom_rotate_any": "geom_rotate_any",
5681
+
5545
5682
  "invert": "geom_invert",
5546
5683
  "geom_invert": "geom_invert",
5547
5684
 
@@ -6542,6 +6679,17 @@ class AstroSuiteProMainWindow(
6542
6679
  QMessageBox.warning(self, "Rotate 180Â deg", str(e))
6543
6680
  return
6544
6681
 
6682
+ if cid == "geom_rotate_any":
6683
+ try:
6684
+ angle = float(preset.get("angle_deg", preset.get("angle", 0.0)))
6685
+ called = _call_any(["_apply_geom_rot_any_to_doc"], doc, angle_deg=angle)
6686
+ if not called:
6687
+ raise RuntimeError("No rotate-any apply method found")
6688
+ self._log(f"Rotate ({angle:g}°) applied to '{target_sw.windowTitle()}'")
6689
+ except Exception as e:
6690
+ QMessageBox.warning(self, "Rotate...", str(e))
6691
+ return
6692
+
6545
6693
  if cid == "geom_rescale":
6546
6694
  try:
6547
6695
  factor = float(preset.get("factor", 1.0))
@@ -7347,7 +7495,7 @@ class AstroSuiteProMainWindow(
7347
7495
  self._search_dock = None
7348
7496
 
7349
7497
  # --- Right-side mini dock with the search box ---
7350
- self._search_dock = QDockWidget("Command Search", self)
7498
+ self._search_dock = QDockWidget(self.tr("Command Search"), self)
7351
7499
  self._search_dock.setObjectName("CommandSearchDock")
7352
7500
  # âœ... Allow moving/closing like other panels
7353
7501
  self._search_dock.setAllowedAreas(
@@ -7562,7 +7710,7 @@ class AstroSuiteProMainWindow(
7562
7710
  except Exception:
7563
7711
  pass
7564
7712
 
7565
- try: self.update_undo_redo_action_labels()
7713
+ try: self._schedule_undo_redo_label_refresh()
7566
7714
  except Exception as e:
7567
7715
  import logging
7568
7716
  logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
@@ -7617,12 +7765,33 @@ class AstroSuiteProMainWindow(
7617
7765
  pass
7618
7766
 
7619
7767
  def _pretty_title(self, doc, *, linked: bool | None = None) -> str:
7620
- name = getattr(doc, "display_name", lambda: "Untitled")()
7621
- name = name.replace("[LINK] ", "").strip()
7768
+ md = (getattr(doc, "metadata", {}) or {})
7769
+
7770
+ # ✅ 1) Prefer explicit display_name (what duplicate/rename intends)
7771
+ name = (md.get("display_name") or "").strip()
7772
+
7773
+ # 2) Fallback to file_path (but only if display_name is missing)
7774
+ if not name:
7775
+ fp = (md.get("file_path") or "").strip()
7776
+ if fp:
7777
+ name = os.path.splitext(os.path.basename(fp))[0]
7778
+
7779
+ # 3) Fallback to doc.display_name()
7780
+ if not name:
7781
+ name = getattr(doc, "display_name", lambda: "Untitled")()
7782
+ name = (name or "Untitled").replace("[LINK] ", "").strip()
7783
+
7784
+ # If it looks like a filename, drop extension
7785
+ base, ext = os.path.splitext(name)
7786
+ if ext and len(ext) <= 10:
7787
+ name = base
7788
+
7789
+ # linked marker logic
7622
7790
  if linked is None:
7623
- linked = hasattr(doc, "_parent_doc") # ROI proxy -> linked
7791
+ linked = hasattr(doc, "_parent_doc")
7624
7792
  return f"[LINK] {name}" if linked else name
7625
7793
 
7794
+
7626
7795
  def _build_subwindow_title_for_doc(self, doc) -> str:
7627
7796
  """
7628
7797
  Build a unique, human-friendly title for a QMdiSubWindow
@@ -7690,6 +7859,34 @@ class AstroSuiteProMainWindow(
7690
7859
  return cand
7691
7860
  n += 1
7692
7861
 
7862
+
7863
+ def _doc_window_title(self, doc) -> str:
7864
+ md = getattr(doc, "metadata", {}) or {}
7865
+
7866
+ t = (md.get("display_name") or "").strip()
7867
+ if not t:
7868
+ try:
7869
+ t = (doc.display_name() or "").strip()
7870
+ except Exception:
7871
+ t = ""
7872
+
7873
+ if not t:
7874
+ fp = (md.get("file_path") or "").strip()
7875
+ if fp:
7876
+ t = os.path.splitext(os.path.basename(fp))[0] # ✅ strip ext here too
7877
+
7878
+ t = t or "Untitled"
7879
+
7880
+ # strip glyphs etc
7881
+ try:
7882
+ t = _strip_ui_decorations(t)
7883
+ except Exception:
7884
+ pass
7885
+
7886
+ # ✅ ALWAYS strip filename-like extension at the very end
7887
+ t = _strip_filename_ext(t)
7888
+
7889
+ return t
7693
7890
 
7694
7891
  def _spawn_subwindow_for(self, doc, *, force_new: bool = False):
7695
7892
  """
@@ -7793,10 +7990,7 @@ class AstroSuiteProMainWindow(
7793
7990
  if replay_sig is not None:
7794
7991
  try:
7795
7992
  replay_sig.connect(self._on_view_replay_last_requested)
7796
- try:
7797
- self._log(f"[Replay] Connected {sig_name_used} for view id={id(view)}")
7798
- except Exception:
7799
- print(f"[Replay] Connected {sig_name_used} for view id={id(view)}")
7993
+
7800
7994
  except Exception as e:
7801
7995
  try:
7802
7996
  self._log(f"[Replay] FAILED to connect {sig_name_used} for view id={id(view)}: {e}")
@@ -7804,7 +7998,8 @@ class AstroSuiteProMainWindow(
7804
7998
  print(f"[Replay] FAILED to connect {sig_name_used} for view id={id(view)}: {e}")
7805
7999
 
7806
8000
  self._hook_preview_awareness(view)
7807
- base_title = self._pretty_title(doc, linked=False)
8001
+
8002
+ base_title = self._doc_window_title(doc) # ✅ use metadata display_name
7808
8003
  final_title = self._unique_window_title(base_title)
7809
8004
 
7810
8005
  # -- 6) Add subwindow and set chrome
@@ -7828,7 +8023,8 @@ class AstroSuiteProMainWindow(
7828
8023
  # We target ~60% of the viewport height, clamped to sane bounds.
7829
8024
  # -------------------------------------------------------------------------
7830
8025
  vp = self.mdi.viewport()
7831
- area = vp.rect() if vp else self.mdi.rect()
8026
+ # Use viewport geometry in MDI coordinates (NOT viewport-local rect)
8027
+ area = vp.geometry() if vp else self.mdi.contentsRect()
7832
8028
 
7833
8029
  # Determine aspect ratio
7834
8030
  img_w = img_h = None
@@ -7871,7 +8067,7 @@ class AstroSuiteProMainWindow(
7871
8067
  # Smart Cascade: Position relative to the *currently active* window
7872
8068
  # (before we make the new one active).
7873
8069
  # -------------------------------------------------------------------------
7874
- new_x, new_y = 0, 0
8070
+ new_x, new_y = area.left(), area.top()
7875
8071
 
7876
8072
  # Get dominant/active window *before* we activate the new one
7877
8073
  active = self.mdi.activeSubWindow()
@@ -7894,15 +8090,13 @@ class AstroSuiteProMainWindow(
7894
8090
  except Exception:
7895
8091
  pass
7896
8092
 
7897
- # Bounds check: don't let it drift completely off-screen
7898
- # (allow valid title bar to be visible at least)
7899
- if (new_x + target_w > area.width() + 50) or (new_y + 50 > area.height()):
7900
- new_x = 0
7901
- new_y = 0
7902
-
7903
- # Clamp to 0 if negative for some reason
7904
- new_x = max(0, new_x)
7905
- new_y = max(0, new_y)
8093
+ # Bounds check: keep titlebar visible and stay inside viewport
8094
+ if (new_x + target_w > area.right() - 10) or (new_y + 40 > area.bottom() - 10):
8095
+ new_x = area.left()
8096
+ new_y = area.top()
8097
+
8098
+ new_x = max(area.left(), new_x)
8099
+ new_y = max(area.top(), new_y)
7906
8100
 
7907
8101
  sw.move(new_x, new_y)
7908
8102
 
@@ -7997,6 +8191,11 @@ class AstroSuiteProMainWindow(
7997
8191
  except Exception:
7998
8192
  pass
7999
8193
 
8194
+ try:
8195
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8196
+ except Exception:
8197
+ pass
8198
+
8000
8199
  # -- 11) If this is the first window and it's an image, mimic "Cascade Views"
8001
8200
  try:
8002
8201
  if first_window and not is_table:
@@ -8050,7 +8249,7 @@ class AstroSuiteProMainWindow(
8050
8249
  # If no subwindows remain, clear all "active doc" UI bits, including header
8051
8250
  if not self.mdi.subWindowList():
8052
8251
  self.currentDocumentChanged.emit(None) # drives HeaderViewerDock.set_document(None)
8053
- self.update_undo_redo_action_labels()
8252
+ self._schedule_undo_redo_label_refresh()
8054
8253
  self._hdr_refresh_timer.start(0) # belt-and-suspenders for manual widgets
8055
8254
  # If your dock has its own set_document, call it explicitly too
8056
8255
  hv = getattr(self, "header_viewer", None)
@@ -8091,25 +8290,57 @@ class AstroSuiteProMainWindow(
8091
8290
  "autostretch_target": float(getattr(source_view, "autostretch_target", 0.25)),
8092
8291
  }
8093
8292
 
8094
- # 2) New name (strip UI decorations if any)
8095
- base_name = ""
8293
+ # 2) New name (normalized: NO decorators like 🔗■●◆▲▪▫•◼◻◾◽)
8096
8294
  try:
8097
- base_name = base_doc.display_name() or "Untitled"
8295
+ base_name = self._doc_window_title(base_doc) # might include decorations
8098
8296
  except Exception:
8099
8297
  base_name = "Untitled"
8100
8298
 
8299
+ # Normalize it so uniqueness checks don't miss decorated titles
8101
8300
  try:
8102
- base_name = _strip_ui_decorations(base_name)
8301
+ base_name = normalize_doc_title(base_name)
8302
+ except Exception:
8303
+ base_name = (base_name or "Untitled").strip()
8304
+
8305
+ # Build a set of existing document names (normalized)
8306
+ existing = set()
8307
+ try:
8308
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
8309
+ docs = []
8310
+
8311
+ # Prefer an official accessor if you have one
8312
+ if dm is not None:
8313
+ if hasattr(dm, "documents"):
8314
+ docs = list(dm.documents())
8315
+ elif hasattr(dm, "_docs"):
8316
+ docs = list(dm._docs)
8317
+
8318
+ for d in docs:
8319
+ try:
8320
+ dn = ""
8321
+ md = getattr(d, "metadata", {}) or {}
8322
+ dn = (md.get("display_name") or "").strip() or (d.display_name() or "").strip()
8323
+ dn = normalize_doc_title(dn)
8324
+ if dn:
8325
+ existing.add(dn)
8326
+ except Exception:
8327
+ pass
8103
8328
  except Exception:
8104
- # minimal fallback: remove our known prefix/glyphs
8105
- while len(base_name) >= 2 and base_name[1] == " " and base_name[0] in "â- â--â--†â-²â-ªâ-«â€¢â--¼â--»â--¾â--½":
8106
- base_name = base_name[2:]
8107
- if base_name.startswith("Active View: "):
8108
- base_name = base_name[len("Active View: "):]
8329
+ pass
8330
+
8331
+ # Pick a unique duplicate name: base_duplicate, base_duplicate2, ...
8332
+ candidate = f"{base_name}_duplicate"
8333
+ if candidate in existing:
8334
+ n = 2
8335
+ while True:
8336
+ cand = f"{base_name}_duplicate{n}"
8337
+ if cand not in existing:
8338
+ candidate = cand
8339
+ break
8340
+ n += 1
8109
8341
 
8110
8342
  # 3) Duplicate the *base* document (not the ROI proxy)
8111
- # NOTE: your project uses `self.docman` elsewhere for duplication.
8112
- new_doc = self.docman.duplicate_document(base_doc, new_name=f"{base_name}_duplicate")
8343
+ new_doc = self.docman.duplicate_document(base_doc, new_name=candidate)
8113
8344
  print(f" Duplicated document ID {id(base_doc)} -> {id(new_doc)}")
8114
8345
 
8115
8346
  # 4) Ensure the duplicate starts mask-free (so we don't inherit mask UI state)
@@ -8259,26 +8490,21 @@ class AstroSuiteProMainWindow(
8259
8490
 
8260
8491
 
8261
8492
  def _activate_or_open_from_explorer(self, item):
8262
- doc = item.data(Qt.ItemDataRole.UserRole)
8263
- base = self._normalize_base_doc(doc)
8264
-
8265
- # 1) Try to focus an existing view for this base
8266
- for sw in self.mdi.subWindowList():
8267
- w = sw.widget()
8268
- if getattr(w, "base_document", None) is base:
8269
- try:
8270
- sw.show(); w.show()
8271
- st = sw.windowState()
8272
- if st & Qt.WindowState.WindowMinimized:
8273
- sw.setWindowState(st & ~Qt.WindowState.WindowMinimized)
8274
- self.mdi.setActiveSubWindow(sw)
8275
- sw.raise_()
8276
- except Exception:
8277
- pass
8278
- return
8279
-
8280
- # 2) None exists -> open one
8281
- self._open_subwindow_for_added_doc(base)
8493
+ doc = item.data(0, Qt.ItemDataRole.UserRole)
8494
+ if doc is None:
8495
+ return
8496
+ # you already have logic for this; typically:
8497
+ sw = self._find_subwindow_for_doc(doc)
8498
+ if sw:
8499
+ self.mdi.setActiveSubWindow(sw)
8500
+ sw.show()
8501
+ sw.raise_()
8502
+ return
8503
+ # else open it (if your app supports opening closed docs, otherwise no-op)
8504
+ try:
8505
+ self._open_subwindow_for_added_doc(doc)
8506
+ except Exception:
8507
+ pass
8282
8508
 
8283
8509
  def _set_linked_stretch_from_action(self, checked: bool):
8284
8510
  # persist as the default for *new* views
@@ -8392,16 +8618,20 @@ class AstroSuiteProMainWindow(
8392
8618
 
8393
8619
  # Misc UI refreshes (guarded)
8394
8620
  try:
8395
- self.update_undo_redo_action_labels()
8621
+ self._schedule_undo_redo_label_refresh()
8396
8622
  except Exception:
8397
8623
  pass
8624
+ #try:
8625
+ # if hasattr(self, "_hdr_refresh_timer") and self._hdr_refresh_timer is not None:
8626
+ # self._hdr_refresh_timer.start(0)
8627
+ #except Exception:
8628
+ # pass
8398
8629
  try:
8399
- if hasattr(self, "_hdr_refresh_timer") and self._hdr_refresh_timer is not None:
8400
- self._hdr_refresh_timer.start(0)
8630
+ self._refresh_mask_action_states()
8401
8631
  except Exception:
8402
8632
  pass
8403
8633
  try:
8404
- self._refresh_mask_action_states()
8634
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8405
8635
  except Exception:
8406
8636
  pass
8407
8637