setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.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 (68) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/colorwheel.svg +97 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/graxpert.svg +19 -0
  6. setiastro/images/linearfit.svg +32 -0
  7. setiastro/images/narrowbandnormalization.png +0 -0
  8. setiastro/images/pixelmath.svg +42 -0
  9. setiastro/images/planetarystacker.png +0 -0
  10. setiastro/saspro/__main__.py +1 -1
  11. setiastro/saspro/_generated/build_info.py +2 -2
  12. setiastro/saspro/aberration_ai.py +49 -11
  13. setiastro/saspro/aberration_ai_preset.py +29 -3
  14. setiastro/saspro/add_stars.py +29 -5
  15. setiastro/saspro/backgroundneutral.py +73 -33
  16. setiastro/saspro/blink_comparator_pro.py +150 -55
  17. setiastro/saspro/convo.py +9 -6
  18. setiastro/saspro/cosmicclarity.py +125 -18
  19. setiastro/saspro/crop_dialog_pro.py +96 -2
  20. setiastro/saspro/curve_editor_pro.py +132 -61
  21. setiastro/saspro/curves_preset.py +249 -47
  22. setiastro/saspro/doc_manager.py +178 -11
  23. setiastro/saspro/frequency_separation.py +1159 -208
  24. setiastro/saspro/gui/main_window.py +340 -88
  25. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  26. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  27. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  28. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  29. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  30. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  31. setiastro/saspro/histogram.py +179 -7
  32. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  33. setiastro/saspro/imageops/serloader.py +769 -0
  34. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  35. setiastro/saspro/imageops/stretch.py +582 -62
  36. setiastro/saspro/layers.py +13 -9
  37. setiastro/saspro/layers_dock.py +183 -3
  38. setiastro/saspro/legacy/numba_utils.py +68 -48
  39. setiastro/saspro/live_stacking.py +181 -73
  40. setiastro/saspro/multiscale_decomp.py +77 -29
  41. setiastro/saspro/narrowband_normalization.py +1618 -0
  42. setiastro/saspro/numba_utils.py +72 -57
  43. setiastro/saspro/ops/commands.py +18 -18
  44. setiastro/saspro/ops/script_editor.py +5 -0
  45. setiastro/saspro/ops/scripts.py +119 -0
  46. setiastro/saspro/remove_green.py +1 -1
  47. setiastro/saspro/resources.py +4 -0
  48. setiastro/saspro/ser_stack_config.py +68 -0
  49. setiastro/saspro/ser_stacker.py +2245 -0
  50. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  51. setiastro/saspro/ser_tracking.py +206 -0
  52. setiastro/saspro/serviewer.py +1242 -0
  53. setiastro/saspro/sfcc.py +602 -214
  54. setiastro/saspro/shortcuts.py +154 -25
  55. setiastro/saspro/signature_insert.py +688 -33
  56. setiastro/saspro/stacking_suite.py +853 -401
  57. setiastro/saspro/star_alignment.py +243 -122
  58. setiastro/saspro/stat_stretch.py +878 -131
  59. setiastro/saspro/subwindow.py +303 -74
  60. setiastro/saspro/whitebalance.py +24 -0
  61. setiastro/saspro/widgets/common_utilities.py +28 -21
  62. setiastro/saspro/widgets/resource_monitor.py +128 -80
  63. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  64. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
  65. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  66. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  67. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  68. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.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, QElapsedTimer
132
+ QPropertyAnimation, QEasingCurve, QElapsedTimer, QPoint
133
133
  )
134
134
 
135
135
  from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
@@ -193,9 +193,9 @@ from setiastro.saspro.resources import (
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
- colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path,
196
+ colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path, narrowbandnormalization_path,
197
197
  wimi_path, linearfit_path, debayer_path, aberration_path, acv_icon_path,
198
- functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path,
198
+ functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path, planetarystacker_path,
199
199
  background_path, script_icon_path
200
200
  )
201
201
 
@@ -361,6 +361,69 @@ class UiStallDetector(QObject):
361
361
  print(f"[UI STALL] tick late by {late_ms:.0f} ms (elapsed={elapsed_ms:.0f} ms)", flush=True)
362
362
  self._dump_all_threads_print()
363
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()
426
+
364
427
  class AstroSuiteProMainWindow(
365
428
  DockMixin, MenuMixin, ToolbarMixin, FileMixin,
366
429
  ThemeMixin, GeometryMixin, ViewMixin, HeaderMixin, MaskMixin, UpdateMixin,
@@ -541,7 +604,10 @@ class AstroSuiteProMainWindow(
541
604
  self.docman.documentAdded.connect(self._on_document_added)
542
605
  self.mdi.viewStateDropped.connect(self._on_mdi_viewstate_drop)
543
606
  self.mdi.linkViewDropped.connect(self._on_linkview_drop)
544
-
607
+ self._mdi_open_batch = 0
608
+ self._mdi_place_mode = "cascade" # or "tile"
609
+ self._mdi_next_pos = None # QPoint in MDI coords
610
+ self._mdi_cascade_step = 28
545
611
  self.doc_manager.set_mdi_area(self.mdi)
546
612
  # Coalesce undo/redo label refreshes
547
613
  self._undo_redo_refresh_pending = False
@@ -2597,10 +2663,27 @@ class AstroSuiteProMainWindow(
2597
2663
  return f"{name}{dims}"
2598
2664
 
2599
2665
  def _update_explorer_item_for_doc(self, doc):
2600
- for i in range(self.explorer.count()):
2601
- it = self.explorer.item(i)
2602
- if it.data(Qt.ItemDataRole.UserRole) is doc:
2603
- it.setText(self._format_explorer_title(doc))
2666
+ # Delegate to DockMixin implementation if present
2667
+ try:
2668
+ return super()._update_explorer_item_for_doc(doc)
2669
+ except Exception:
2670
+ pass
2671
+
2672
+ # Fallback: tree-safe implementation
2673
+ if not hasattr(self, "explorer") or self.explorer is None:
2674
+ return
2675
+ try:
2676
+ n = self.explorer.topLevelItemCount()
2677
+ except Exception:
2678
+ return
2679
+
2680
+ for i in range(n):
2681
+ it = self.explorer.topLevelItem(i)
2682
+ if it.data(0, Qt.ItemDataRole.UserRole) is doc:
2683
+ try:
2684
+ self._refresh_explorer_row(it, doc)
2685
+ except Exception:
2686
+ pass
2604
2687
  return
2605
2688
  #-----------FUNCTIONS----------------
2606
2689
 
@@ -3825,6 +3908,19 @@ class AstroSuiteProMainWindow(
3825
3908
 
3826
3909
  dlg.show()
3827
3910
 
3911
+ def _open_narrowband_normalization_tool(self):
3912
+ # Correct module import
3913
+ from setiastro.saspro.narrowband_normalization import NarrowbandNormalization
3914
+
3915
+ w = NarrowbandNormalization(doc_manager=self.docman, parent=self)
3916
+ w.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
3917
+ w.setWindowTitle("Narrowband Normalization")
3918
+ try:
3919
+ w.setWindowIcon(QIcon(narrowbandnormalization_path))
3920
+ except Exception:
3921
+ pass
3922
+ w.show()
3923
+
3828
3924
  def _open_ppp_tool(self):
3829
3925
  from setiastro.saspro.perfect_palette_picker import PerfectPalettePicker
3830
3926
  w = PerfectPalettePicker(doc_manager=self.docman) # parent gives access to _spawn_subwindow_for
@@ -4176,6 +4272,14 @@ class AstroSuiteProMainWindow(
4176
4272
  dlg.setWindowIcon(QIcon(livestacking_path))
4177
4273
  dlg.show()
4178
4274
 
4275
+ def _open_planetary_stacker(self):
4276
+ # import locally to avoid startup cost / circular imports
4277
+ from setiastro.saspro.serviewer import SERViewer
4278
+ dlg = SERViewer(self)
4279
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
4280
+ dlg.setWindowIcon(QIcon(planetarystacker_path))
4281
+ dlg.show()
4282
+
4179
4283
  def _open_stacking_suite(self):
4180
4284
  # Reuse if we already have one
4181
4285
  dlg = getattr(self, "_stacking_suite", None)
@@ -4219,6 +4323,115 @@ class AstroSuiteProMainWindow(
4219
4323
  except Exception:
4220
4324
  pass
4221
4325
 
4326
+ def _convert_mono_to_rgb_active(self):
4327
+ """
4328
+ Convert active mono document to RGB by duplicating the channel.
4329
+ Updates the active document in-place (undoable).
4330
+ """
4331
+ dm = getattr(self, "docman", None)
4332
+ if dm is None:
4333
+ return
4334
+
4335
+ try:
4336
+ doc = dm.get_active_document()
4337
+ except Exception:
4338
+ doc = None
4339
+ if doc is None:
4340
+ return
4341
+
4342
+ img = getattr(doc, "image", None)
4343
+ if img is None:
4344
+ return
4345
+
4346
+ import numpy as np
4347
+
4348
+ x = np.asarray(img)
4349
+
4350
+ # Already RGB?
4351
+ if x.ndim == 3 and x.shape[-1] == 3:
4352
+ try:
4353
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4354
+ except Exception:
4355
+ name = "Active"
4356
+ if hasattr(self, "_log"):
4357
+ self._log(f"Mono → RGB: '{name}' is already RGB (shape={getattr(x,'shape',None)}).")
4358
+ return
4359
+
4360
+ # Determine what we're converting FROM
4361
+ src_desc = "unknown"
4362
+ if x.ndim == 2:
4363
+ mono = x
4364
+ src_desc = "mono (H×W)"
4365
+ elif x.ndim == 3 and x.shape[-1] == 1:
4366
+ mono = x[..., 0]
4367
+ src_desc = "mono (H×W×1)"
4368
+ else:
4369
+ # Unknown format (e.g., multi-channel >3)
4370
+ try:
4371
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4372
+ except Exception:
4373
+ name = "Active"
4374
+ if hasattr(self, "_log"):
4375
+ self._log(f"Mono → RGB: '{name}' not convertible (shape={getattr(x,'shape',None)}).")
4376
+ return
4377
+
4378
+ before_shape = getattr(x, "shape", None)
4379
+ before_dtype = getattr(x, "dtype", None)
4380
+
4381
+ mono = mono.astype(np.float32, copy=False)
4382
+ rgb = np.stack([mono, mono, mono], axis=-1)
4383
+
4384
+ # metadata: preserve existing, but force "not mono"
4385
+ try:
4386
+ md = dict(getattr(doc, "metadata", None) or {})
4387
+ except Exception:
4388
+ md = {}
4389
+
4390
+ md["is_mono"] = False
4391
+ md["color_model"] = "RGB"
4392
+ md["channels"] = 3
4393
+ md["source"] = (md.get("source") or "Edit")
4394
+
4395
+ # If you track op params for history explorer
4396
+ md["__op_params__"] = {
4397
+ "op": "mono_to_rgb",
4398
+ "mode": "triplicate",
4399
+ "from": str(src_desc),
4400
+ "from_shape": tuple(before_shape) if before_shape is not None else None,
4401
+ "to_shape": tuple(rgb.shape),
4402
+ }
4403
+
4404
+ # name for logging
4405
+ try:
4406
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4407
+ except Exception:
4408
+ name = "Active"
4409
+
4410
+ try:
4411
+ dm.update_active_document(
4412
+ rgb,
4413
+ metadata=md,
4414
+ step_name="Mono → RGB",
4415
+ doc=doc, # explicit is safer
4416
+ )
4417
+
4418
+ if hasattr(self, "_log"):
4419
+ self._log(
4420
+ f"Mono → RGB: '{name}' converted {src_desc} "
4421
+ f"(shape={before_shape}, dtype={before_dtype}) → "
4422
+ f"RGB (shape={rgb.shape}, dtype={rgb.dtype})."
4423
+ )
4424
+
4425
+ except Exception:
4426
+ import traceback
4427
+ try:
4428
+ from PyQt6.QtWidgets import QMessageBox
4429
+ QMessageBox.critical(self, "Mono → RGB", traceback.format_exc())
4430
+ except Exception:
4431
+ pass
4432
+
4433
+
4434
+
4222
4435
  def _on_stackingsuite_relaunch(self, old_dir: str, new_dir: str):
4223
4436
  # Optional: respond to dialog's relaunch request
4224
4437
  try:
@@ -7779,12 +7992,8 @@ class AstroSuiteProMainWindow(
7779
7992
  return cand
7780
7993
  n += 1
7781
7994
 
7995
+
7782
7996
  def _doc_window_title(self, doc) -> str:
7783
- """
7784
- Best-effort human title for a subwindow.
7785
- Prefer metadata['display_name'] (what duplication sets),
7786
- then doc.display_name(), then basename(file_path).
7787
- """
7788
7997
  md = getattr(doc, "metadata", {}) or {}
7789
7998
 
7790
7999
  t = (md.get("display_name") or "").strip()
@@ -7797,8 +8006,7 @@ class AstroSuiteProMainWindow(
7797
8006
  if not t:
7798
8007
  fp = (md.get("file_path") or "").strip()
7799
8008
  if fp:
7800
- import os
7801
- t = os.path.basename(fp)
8009
+ t = os.path.splitext(os.path.basename(fp))[0] # ✅ strip ext here too
7802
8010
 
7803
8011
  t = t or "Untitled"
7804
8012
 
@@ -7807,8 +8015,48 @@ class AstroSuiteProMainWindow(
7807
8015
  t = _strip_ui_decorations(t)
7808
8016
  except Exception:
7809
8017
  pass
8018
+
8019
+ # ✅ ALWAYS strip filename-like extension at the very end
8020
+ t = _strip_filename_ext(t)
8021
+
7810
8022
  return t
7811
8023
 
8024
+ def _mdi_begin_open_batch(self, mode: str = "cascade"):
8025
+ self._mdi_open_batch += 1
8026
+ self._mdi_place_mode = mode or "cascade"
8027
+ self._mdi_next_pos = None
8028
+
8029
+ def _mdi_end_open_batch(self):
8030
+ self._mdi_open_batch = max(0, self._mdi_open_batch - 1)
8031
+ if self._mdi_open_batch == 0:
8032
+ self._mdi_next_pos = None
8033
+
8034
+ def _mdi_compute_initial_pos(self) -> QPoint:
8035
+ area = (self.mdi.viewport().geometry() if self.mdi.viewport() else self.mdi.contentsRect())
8036
+ # Put first window a bit inset so titlebars don’t clip
8037
+ return QPoint(area.left() + 18, area.top() + 18)
8038
+
8039
+ def _mdi_place_subwindow(self, sw, target_w: int, target_h: int):
8040
+ """Deterministic placement. Uses a stable cursor during batch opens."""
8041
+ vp = self.mdi.viewport()
8042
+ area = vp.geometry() if vp else self.mdi.contentsRect()
8043
+
8044
+ if self._mdi_next_pos is None:
8045
+ self._mdi_next_pos = self._mdi_compute_initial_pos()
8046
+
8047
+ x = self._mdi_next_pos.x()
8048
+ y = self._mdi_next_pos.y()
8049
+
8050
+ # keep inside viewport; reset when we hit edge
8051
+ if (x + target_w > area.right() - 10) or (y + 40 > area.bottom() - 10):
8052
+ x = area.left() + 18
8053
+ y = area.top() + 18
8054
+
8055
+ sw.move(x, y)
8056
+
8057
+ # advance cursor
8058
+ step = int(self._mdi_cascade_step)
8059
+ self._mdi_next_pos = QPoint(x + step, y + step)
7812
8060
 
7813
8061
  def _spawn_subwindow_for(self, doc, *, force_new: bool = False):
7814
8062
  """
@@ -7945,7 +8193,8 @@ class AstroSuiteProMainWindow(
7945
8193
  # We target ~60% of the viewport height, clamped to sane bounds.
7946
8194
  # -------------------------------------------------------------------------
7947
8195
  vp = self.mdi.viewport()
7948
- area = vp.rect() if vp else self.mdi.rect()
8196
+ # Use viewport geometry in MDI coordinates (NOT viewport-local rect)
8197
+ area = vp.geometry() if vp else self.mdi.contentsRect()
7949
8198
 
7950
8199
  # Determine aspect ratio
7951
8200
  img_w = img_h = None
@@ -7982,54 +8231,22 @@ class AstroSuiteProMainWindow(
7982
8231
  target_h = max(200, target_h)
7983
8232
 
7984
8233
  sw.resize(target_w, target_h)
7985
- sw.showNormal() # CRITICAL: clears any "maximized" flag from previous active window
8234
+ sw.showNormal() # clears any "maximized" flag from previous active window
7986
8235
 
7987
- # -------------------------------------------------------------------------
7988
- # Smart Cascade: Position relative to the *currently active* window
7989
- # (before we make the new one active).
7990
- # -------------------------------------------------------------------------
7991
- new_x, new_y = 0, 0
7992
-
7993
- # Get dominant/active window *before* we activate the new one
7994
- active = self.mdi.activeSubWindow()
7995
- if active and active.isVisible() and not (active.windowState() & Qt.WindowState.WindowMinimized):
7996
- # Cascade from the active window
7997
- geo = active.geometry()
7998
- new_x = geo.x() + 30
7999
- new_y = geo.y() + 30
8000
- else:
8001
- # Fallback: try to find the "last added" visible window to cascade from
8002
- # (useful if active is None but windows exist)
8003
- try:
8004
- subs = [s for s in self.mdi.subWindowList() if s.isVisible() and s is not sw]
8005
- if subs:
8006
- # simplistic "last created" might be at end of list
8007
- last = subs[-1]
8008
- geo = last.geometry()
8009
- new_x = geo.x() + 30
8010
- new_y = geo.y() + 30
8236
+ # Deterministic placement (batch-aware)
8237
+ try:
8238
+ self._mdi_place_subwindow(sw, target_w, target_h)
8239
+ except Exception:
8240
+ # absolute fallback: top-left-ish
8241
+ try:
8242
+ sw.move(area.left() + 18, area.top() + 18)
8011
8243
  except Exception:
8012
8244
  pass
8013
8245
 
8014
- # Bounds check: don't let it drift completely off-screen
8015
- # (allow valid title bar to be visible at least)
8016
- if (new_x + target_w > area.width() + 50) or (new_y + 50 > area.height()):
8017
- new_x = 0
8018
- new_y = 0
8019
-
8020
- # Clamp to 0 if negative for some reason
8021
- new_x = max(0, new_x)
8022
- new_y = max(0, new_y)
8023
-
8024
- sw.move(new_x, new_y)
8025
-
8026
- # ❌ removed the "fill MDI viewport" block - we *don't* want full-monitor first window
8027
-
8028
8246
  # Show / activate
8029
8247
  sw.show()
8030
8248
  sw.raise_()
8031
8249
  self.mdi.setActiveSubWindow(sw)
8032
- # (no second setWindowTitle() here)
8033
8250
 
8034
8251
  # Optional minimize/restore interceptor
8035
8252
  if hasattr(self, "_minimize_interceptor"):
@@ -8114,6 +8331,11 @@ class AstroSuiteProMainWindow(
8114
8331
  except Exception:
8115
8332
  pass
8116
8333
 
8334
+ try:
8335
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8336
+ except Exception:
8337
+ pass
8338
+
8117
8339
  # -- 11) If this is the first window and it's an image, mimic "Cascade Views"
8118
8340
  try:
8119
8341
  if first_window and not is_table:
@@ -8208,25 +8430,57 @@ class AstroSuiteProMainWindow(
8208
8430
  "autostretch_target": float(getattr(source_view, "autostretch_target", 0.25)),
8209
8431
  }
8210
8432
 
8211
- # 2) New name (strip UI decorations if any)
8212
- base_name = ""
8433
+ # 2) New name (normalized: NO decorators like 🔗■●◆▲▪▫•◼◻◾◽)
8213
8434
  try:
8214
- base_name = base_doc.display_name() or "Untitled"
8435
+ base_name = self._doc_window_title(base_doc) # might include decorations
8215
8436
  except Exception:
8216
8437
  base_name = "Untitled"
8217
8438
 
8439
+ # Normalize it so uniqueness checks don't miss decorated titles
8440
+ try:
8441
+ base_name = normalize_doc_title(base_name)
8442
+ except Exception:
8443
+ base_name = (base_name or "Untitled").strip()
8444
+
8445
+ # Build a set of existing document names (normalized)
8446
+ existing = set()
8218
8447
  try:
8219
- base_name = _strip_ui_decorations(base_name)
8448
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
8449
+ docs = []
8450
+
8451
+ # Prefer an official accessor if you have one
8452
+ if dm is not None:
8453
+ if hasattr(dm, "documents"):
8454
+ docs = list(dm.documents())
8455
+ elif hasattr(dm, "_docs"):
8456
+ docs = list(dm._docs)
8457
+
8458
+ for d in docs:
8459
+ try:
8460
+ dn = ""
8461
+ md = getattr(d, "metadata", {}) or {}
8462
+ dn = (md.get("display_name") or "").strip() or (d.display_name() or "").strip()
8463
+ dn = normalize_doc_title(dn)
8464
+ if dn:
8465
+ existing.add(dn)
8466
+ except Exception:
8467
+ pass
8220
8468
  except Exception:
8221
- # minimal fallback: remove our known prefix/glyphs
8222
- while len(base_name) >= 2 and base_name[1] == " " and base_name[0] in "â- â--â--†â-²â-ªâ-«â€¢â--¼â--»â--¾â--½":
8223
- base_name = base_name[2:]
8224
- if base_name.startswith("Active View: "):
8225
- base_name = base_name[len("Active View: "):]
8469
+ pass
8470
+
8471
+ # Pick a unique duplicate name: base_duplicate, base_duplicate2, ...
8472
+ candidate = f"{base_name}_duplicate"
8473
+ if candidate in existing:
8474
+ n = 2
8475
+ while True:
8476
+ cand = f"{base_name}_duplicate{n}"
8477
+ if cand not in existing:
8478
+ candidate = cand
8479
+ break
8480
+ n += 1
8226
8481
 
8227
8482
  # 3) Duplicate the *base* document (not the ROI proxy)
8228
- # NOTE: your project uses `self.docman` elsewhere for duplication.
8229
- new_doc = self.docman.duplicate_document(base_doc, new_name=f"{base_name}_duplicate")
8483
+ new_doc = self.docman.duplicate_document(base_doc, new_name=candidate)
8230
8484
  print(f" Duplicated document ID {id(base_doc)} -> {id(new_doc)}")
8231
8485
 
8232
8486
  # 4) Ensure the duplicate starts mask-free (so we don't inherit mask UI state)
@@ -8376,26 +8630,21 @@ class AstroSuiteProMainWindow(
8376
8630
 
8377
8631
 
8378
8632
  def _activate_or_open_from_explorer(self, item):
8379
- doc = item.data(Qt.ItemDataRole.UserRole)
8380
- base = self._normalize_base_doc(doc)
8381
-
8382
- # 1) Try to focus an existing view for this base
8383
- for sw in self.mdi.subWindowList():
8384
- w = sw.widget()
8385
- if getattr(w, "base_document", None) is base:
8386
- try:
8387
- sw.show(); w.show()
8388
- st = sw.windowState()
8389
- if st & Qt.WindowState.WindowMinimized:
8390
- sw.setWindowState(st & ~Qt.WindowState.WindowMinimized)
8391
- self.mdi.setActiveSubWindow(sw)
8392
- sw.raise_()
8393
- except Exception:
8394
- pass
8395
- return
8396
-
8397
- # 2) None exists -> open one
8398
- self._open_subwindow_for_added_doc(base)
8633
+ doc = item.data(0, Qt.ItemDataRole.UserRole)
8634
+ if doc is None:
8635
+ return
8636
+ # you already have logic for this; typically:
8637
+ sw = self._find_subwindow_for_doc(doc)
8638
+ if sw:
8639
+ self.mdi.setActiveSubWindow(sw)
8640
+ sw.show()
8641
+ sw.raise_()
8642
+ return
8643
+ # else open it (if your app supports opening closed docs, otherwise no-op)
8644
+ try:
8645
+ self._open_subwindow_for_added_doc(doc)
8646
+ except Exception:
8647
+ pass
8399
8648
 
8400
8649
  def _set_linked_stretch_from_action(self, checked: bool):
8401
8650
  # persist as the default for *new* views
@@ -8521,7 +8770,10 @@ class AstroSuiteProMainWindow(
8521
8770
  self._refresh_mask_action_states()
8522
8771
  except Exception:
8523
8772
  pass
8524
-
8773
+ try:
8774
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8775
+ except Exception:
8776
+ pass
8525
8777
 
8526
8778
  def _sync_docman_active(self, doc):
8527
8779
  dm = self.doc_manager