setiastrosuitepro 1.6.7__py3-none-any.whl → 1.6.10__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.
Files changed (37) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/cosmic.svg +40 -0
  3. setiastro/images/cosmicsat.svg +24 -0
  4. setiastro/images/graxpert.svg +19 -0
  5. setiastro/images/linearfit.svg +32 -0
  6. setiastro/images/pixelmath.svg +42 -0
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/add_stars.py +29 -5
  9. setiastro/saspro/blink_comparator_pro.py +74 -24
  10. setiastro/saspro/cosmicclarity.py +125 -18
  11. setiastro/saspro/crop_dialog_pro.py +96 -2
  12. setiastro/saspro/curve_editor_pro.py +60 -39
  13. setiastro/saspro/frequency_separation.py +1159 -208
  14. setiastro/saspro/gui/main_window.py +131 -31
  15. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  16. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  17. setiastro/saspro/imageops/stretch.py +531 -62
  18. setiastro/saspro/layers.py +13 -9
  19. setiastro/saspro/layers_dock.py +183 -3
  20. setiastro/saspro/legacy/numba_utils.py +43 -0
  21. setiastro/saspro/live_stacking.py +158 -70
  22. setiastro/saspro/multiscale_decomp.py +47 -12
  23. setiastro/saspro/numba_utils.py +72 -2
  24. setiastro/saspro/ops/commands.py +18 -18
  25. setiastro/saspro/shortcuts.py +122 -12
  26. setiastro/saspro/signature_insert.py +688 -33
  27. setiastro/saspro/stacking_suite.py +523 -316
  28. setiastro/saspro/stat_stretch.py +688 -130
  29. setiastro/saspro/subwindow.py +302 -71
  30. setiastro/saspro/widgets/common_utilities.py +28 -21
  31. setiastro/saspro/widgets/resource_monitor.py +7 -7
  32. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
  33. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
  34. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  35. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  36. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  37. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
@@ -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,
@@ -7779,12 +7842,8 @@ class AstroSuiteProMainWindow(
7779
7842
  return cand
7780
7843
  n += 1
7781
7844
 
7845
+
7782
7846
  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
7847
  md = getattr(doc, "metadata", {}) or {}
7789
7848
 
7790
7849
  t = (md.get("display_name") or "").strip()
@@ -7797,8 +7856,7 @@ class AstroSuiteProMainWindow(
7797
7856
  if not t:
7798
7857
  fp = (md.get("file_path") or "").strip()
7799
7858
  if fp:
7800
- import os
7801
- t = os.path.basename(fp)
7859
+ t = os.path.splitext(os.path.basename(fp))[0] # ✅ strip ext here too
7802
7860
 
7803
7861
  t = t or "Untitled"
7804
7862
 
@@ -7807,8 +7865,11 @@ class AstroSuiteProMainWindow(
7807
7865
  t = _strip_ui_decorations(t)
7808
7866
  except Exception:
7809
7867
  pass
7810
- return t
7811
7868
 
7869
+ # ✅ ALWAYS strip filename-like extension at the very end
7870
+ t = _strip_filename_ext(t)
7871
+
7872
+ return t
7812
7873
 
7813
7874
  def _spawn_subwindow_for(self, doc, *, force_new: bool = False):
7814
7875
  """
@@ -7945,7 +8006,8 @@ class AstroSuiteProMainWindow(
7945
8006
  # We target ~60% of the viewport height, clamped to sane bounds.
7946
8007
  # -------------------------------------------------------------------------
7947
8008
  vp = self.mdi.viewport()
7948
- area = vp.rect() if vp else self.mdi.rect()
8009
+ # Use viewport geometry in MDI coordinates (NOT viewport-local rect)
8010
+ area = vp.geometry() if vp else self.mdi.contentsRect()
7949
8011
 
7950
8012
  # Determine aspect ratio
7951
8013
  img_w = img_h = None
@@ -7988,7 +8050,7 @@ class AstroSuiteProMainWindow(
7988
8050
  # Smart Cascade: Position relative to the *currently active* window
7989
8051
  # (before we make the new one active).
7990
8052
  # -------------------------------------------------------------------------
7991
- new_x, new_y = 0, 0
8053
+ new_x, new_y = area.left(), area.top()
7992
8054
 
7993
8055
  # Get dominant/active window *before* we activate the new one
7994
8056
  active = self.mdi.activeSubWindow()
@@ -8011,15 +8073,13 @@ class AstroSuiteProMainWindow(
8011
8073
  except Exception:
8012
8074
  pass
8013
8075
 
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)
8076
+ # Bounds check: keep titlebar visible and stay inside viewport
8077
+ if (new_x + target_w > area.right() - 10) or (new_y + 40 > area.bottom() - 10):
8078
+ new_x = area.left()
8079
+ new_y = area.top()
8080
+
8081
+ new_x = max(area.left(), new_x)
8082
+ new_y = max(area.top(), new_y)
8023
8083
 
8024
8084
  sw.move(new_x, new_y)
8025
8085
 
@@ -8114,6 +8174,11 @@ class AstroSuiteProMainWindow(
8114
8174
  except Exception:
8115
8175
  pass
8116
8176
 
8177
+ try:
8178
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8179
+ except Exception:
8180
+ pass
8181
+
8117
8182
  # -- 11) If this is the first window and it's an image, mimic "Cascade Views"
8118
8183
  try:
8119
8184
  if first_window and not is_table:
@@ -8208,25 +8273,57 @@ class AstroSuiteProMainWindow(
8208
8273
  "autostretch_target": float(getattr(source_view, "autostretch_target", 0.25)),
8209
8274
  }
8210
8275
 
8211
- # 2) New name (strip UI decorations if any)
8212
- base_name = ""
8276
+ # 2) New name (normalized: NO decorators like 🔗■●◆▲▪▫•◼◻◾◽)
8213
8277
  try:
8214
- base_name = base_doc.display_name() or "Untitled"
8278
+ base_name = self._doc_window_title(base_doc) # might include decorations
8215
8279
  except Exception:
8216
8280
  base_name = "Untitled"
8217
8281
 
8282
+ # Normalize it so uniqueness checks don't miss decorated titles
8283
+ try:
8284
+ base_name = normalize_doc_title(base_name)
8285
+ except Exception:
8286
+ base_name = (base_name or "Untitled").strip()
8287
+
8288
+ # Build a set of existing document names (normalized)
8289
+ existing = set()
8218
8290
  try:
8219
- base_name = _strip_ui_decorations(base_name)
8291
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
8292
+ docs = []
8293
+
8294
+ # Prefer an official accessor if you have one
8295
+ if dm is not None:
8296
+ if hasattr(dm, "documents"):
8297
+ docs = list(dm.documents())
8298
+ elif hasattr(dm, "_docs"):
8299
+ docs = list(dm._docs)
8300
+
8301
+ for d in docs:
8302
+ try:
8303
+ dn = ""
8304
+ md = getattr(d, "metadata", {}) or {}
8305
+ dn = (md.get("display_name") or "").strip() or (d.display_name() or "").strip()
8306
+ dn = normalize_doc_title(dn)
8307
+ if dn:
8308
+ existing.add(dn)
8309
+ except Exception:
8310
+ pass
8220
8311
  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: "):]
8312
+ pass
8313
+
8314
+ # Pick a unique duplicate name: base_duplicate, base_duplicate2, ...
8315
+ candidate = f"{base_name}_duplicate"
8316
+ if candidate in existing:
8317
+ n = 2
8318
+ while True:
8319
+ cand = f"{base_name}_duplicate{n}"
8320
+ if cand not in existing:
8321
+ candidate = cand
8322
+ break
8323
+ n += 1
8226
8324
 
8227
8325
  # 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")
8326
+ new_doc = self.docman.duplicate_document(base_doc, new_name=candidate)
8230
8327
  print(f" Duplicated document ID {id(base_doc)} -> {id(new_doc)}")
8231
8328
 
8232
8329
  # 4) Ensure the duplicate starts mask-free (so we don't inherit mask UI state)
@@ -8521,7 +8618,10 @@ class AstroSuiteProMainWindow(
8521
8618
  self._refresh_mask_action_states()
8522
8619
  except Exception:
8523
8620
  pass
8524
-
8621
+ try:
8622
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8623
+ except Exception:
8624
+ pass
8525
8625
 
8526
8626
  def _sync_docman_active(self, doc):
8527
8627
  dm = self.doc_manager
@@ -9,12 +9,30 @@ from __future__ import annotations
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  from PyQt6.QtCore import Qt, QTimer
12
- from PyQt6.QtGui import QBrush, QColor, QFont, QPalette
13
- from PyQt6.QtWidgets import QApplication
12
+ from PyQt6.QtGui import QBrush, QColor, QFont, QPalette, QPainter, QPixmap, QIcon
13
+ from PyQt6.QtWidgets import QApplication, QLabel, QWidget
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  pass
17
17
 
18
+ def _force_mdi_subwindow_flags(sw):
19
+ f = sw.windowFlags()
20
+
21
+ # Clear only the *window type* bits (NOT random low bits)
22
+ f &= ~Qt.WindowType.WindowType_Mask
23
+
24
+ # Force true MDI child type
25
+ f |= Qt.WindowType.SubWindow
26
+
27
+ # Add desired buttons/hints
28
+ f |= (Qt.WindowType.CustomizeWindowHint |
29
+ Qt.WindowType.WindowTitleHint |
30
+ Qt.WindowType.WindowSystemMenuHint |
31
+ Qt.WindowType.WindowMinimizeButtonHint |
32
+ Qt.WindowType.WindowMaximizeButtonHint |
33
+ Qt.WindowType.WindowCloseButtonHint)
34
+
35
+ sw.setWindowFlags(f)
18
36
 
19
37
  class ThemeMixin:
20
38
  """
@@ -88,7 +106,7 @@ class ThemeMixin:
88
106
  app.setPalette(self._gray_palette())
89
107
  app.setStyleSheet(
90
108
  "QToolTip { color: #f0f0f0; background-color: #3a3a3a; border: 1px solid #5a5a5a; }"
91
- )
109
+ )
92
110
  elif mode == "light":
93
111
  app.setPalette(self._light_palette())
94
112
  app.setStyleSheet(
@@ -119,6 +137,11 @@ class ThemeMixin:
119
137
  self._repolish_top_levels()
120
138
  self._apply_workspace_theme()
121
139
  self._style_mdi_titlebars()
140
+
141
+ try:
142
+ self._retint_zoom_icons()
143
+ except Exception:
144
+ pass
122
145
  self._menu_view_panels = None
123
146
 
124
147
  try:
@@ -139,26 +162,149 @@ class ThemeMixin:
139
162
  w.setUpdatesEnabled(True)
140
163
 
141
164
  def _style_mdi_titlebars(self):
142
- """Apply theme-specific styles to MDI subwindow titlebars."""
143
165
  mode = self._theme_mode()
166
+
144
167
  if mode == "dark":
145
- base = "#1b1b1b" # inactive titlebar
146
- active = "#242424" # active titlebar
168
+ base = "#1b1b1b"
169
+ active = "#242424"
147
170
  fg = "#dcdcdc"
148
171
  elif mode in ("gray", "custom"):
149
172
  base = "#3a3a3a"
150
173
  active = "#454545"
151
174
  fg = "#f0f0f0"
152
175
  else:
153
- # No override in light / system modes
154
- self.mdi.setStyleSheet("")
155
- return
176
+ base = "#eaeaea"
177
+ active = "#ffffff"
178
+ fg = "#141414"
156
179
 
180
+ # style *our* titlebar only
157
181
  self.mdi.setStyleSheet(f"""
158
- QMdiSubWindow::titlebar {{ background: {base}; color: {fg}; }}
159
- QMdiSubWindow::titlebar:active {{ background: {active}; color: {fg}; }}
182
+ QWidget#sas_mdi_titlebar {{
183
+ background: {base};
184
+ }}
185
+ QWidget#sas_mdi_titlebar[active="true"] {{
186
+ background: {active};
187
+ }}
188
+ QLabel#sas_mdi_title_label {{
189
+ color: {fg};
190
+ background: transparent;
191
+ }}
192
+ QWidget#sas_mdi_titlebar QToolButton {{
193
+ color: {fg};
194
+ background: transparent;
195
+ }}
196
+ QWidget#sas_mdi_titlebar QToolButton:hover {{
197
+ background: rgba(255,255,255,0.10);
198
+ }}
160
199
  """)
161
200
 
201
+
202
+ def _fix_mdi_titlebar_emboss(self, fg_hex: str):
203
+ """
204
+ Fusion/Windows style can draw embossed title text (two-pass).
205
+ In dark themes that can become white-on-white -> 'double text'.
206
+ Force the shadow/emboss colors on the *titlebar widget only*.
207
+ """
208
+ try:
209
+ fg = QColor(fg_hex)
210
+ except Exception:
211
+ fg = QColor(240, 240, 240)
212
+
213
+ for sw in self.mdi.subWindowList():
214
+ try:
215
+ tb = sw.findChild(QWidget, "qt_mdi_titlebar")
216
+ if tb is None:
217
+ continue
218
+
219
+ pal = tb.palette()
220
+
221
+ # Main text
222
+ pal.setColor(QPalette.ColorRole.WindowText, fg)
223
+ pal.setColor(QPalette.ColorRole.Text, fg)
224
+ pal.setColor(QPalette.ColorRole.ButtonText, fg)
225
+
226
+ # Critical: make the embossed/shadow pass dark
227
+ dark = QColor(0, 0, 0)
228
+ pal.setColor(QPalette.ColorRole.Light, dark)
229
+ pal.setColor(QPalette.ColorRole.Midlight, dark)
230
+ pal.setColor(QPalette.ColorRole.Dark, dark)
231
+ pal.setColor(QPalette.ColorRole.Shadow, dark)
232
+
233
+ tb.setPalette(pal)
234
+
235
+ # Also push to the label if present (some styles read it from label)
236
+ lbl = tb.findChild(QLabel)
237
+ if lbl is not None:
238
+ lbl.setPalette(pal)
239
+ except Exception:
240
+ pass
241
+
242
+ def _tint_icon(self, icon: QIcon, color: QColor) -> QIcon:
243
+ """
244
+ Take an existing icon (often fromTheme) and force a single-color glyph.
245
+ Sets Normal and Active to the same tinted pixmaps to prevent hover flipping.
246
+ """
247
+ if icon.isNull():
248
+ return icon
249
+
250
+ out = QIcon()
251
+ sizes = [16, 20, 24, 32, 48, 64]
252
+
253
+ for sz in sizes:
254
+ pm = icon.pixmap(sz, sz, QIcon.Mode.Normal, QIcon.State.Off)
255
+ if pm.isNull():
256
+ continue
257
+
258
+ tinted = QPixmap(pm.size())
259
+ tinted.fill(Qt.GlobalColor.transparent)
260
+
261
+ p = QPainter(tinted)
262
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
263
+ p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
264
+
265
+ # Use the original alpha as a mask, fill with our color
266
+ p.drawPixmap(0, 0, pm)
267
+ p.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
268
+ p.fillRect(tinted.rect(), color)
269
+ p.end()
270
+
271
+ # Normal + Active -> same pixmap (prevents hover flip)
272
+ out.addPixmap(tinted, QIcon.Mode.Normal, QIcon.State.Off)
273
+ out.addPixmap(tinted, QIcon.Mode.Active, QIcon.State.Off)
274
+
275
+ # Disabled: slightly dimmer (optional)
276
+ dis = QColor(color)
277
+ dis.setAlphaF(0.45)
278
+ dispm = QPixmap(tinted.size())
279
+ dispm.fill(Qt.GlobalColor.transparent)
280
+ p2 = QPainter(dispm)
281
+ p2.drawPixmap(0, 0, tinted)
282
+ p2.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
283
+ p2.fillRect(dispm.rect(), dis)
284
+ p2.end()
285
+ out.addPixmap(dispm, QIcon.Mode.Disabled, QIcon.State.Off)
286
+
287
+ return out
288
+
289
+ def _retint_zoom_icons(self):
290
+ """
291
+ Retint only the zoom actions (the ones built from QIcon.fromTheme).
292
+ Call after app palette is applied.
293
+ """
294
+ pal = QApplication.palette()
295
+ glyph = pal.color(QPalette.ColorRole.ButtonText) # or Text; ButtonText tends to match toolbars well
296
+
297
+ for name in ("act_zoom_out", "act_zoom_in", "act_zoom_1_1", "act_zoom_fit"):
298
+ act = getattr(self, name, None)
299
+ if act is None:
300
+ continue
301
+
302
+ # stash original once so repeated theme flips don't re-tint a tinted icon
303
+ if not hasattr(act, "_base_icon"):
304
+ act._base_icon = act.icon()
305
+
306
+ act.setIcon(self._tint_icon(act._base_icon, glyph))
307
+
162
308
  def _dark_palette(self) -> QPalette:
163
309
  """Create a dark theme palette."""
164
310
  p = QPalette()
@@ -172,7 +318,7 @@ class ThemeMixin:
172
318
  hi = QColor(30, 144, 255) # highlight (dodger blue)
173
319
 
174
320
  p.setColor(QPalette.ColorRole.Window, panel)
175
- p.setColor(QPalette.ColorRole.WindowText, text)
321
+ p.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255))
176
322
  p.setColor(QPalette.ColorRole.Base, bg)
177
323
  p.setColor(QPalette.ColorRole.AlternateBase, altbase)
178
324
  p.setColor(QPalette.ColorRole.ToolTipBase, panel)
@@ -270,7 +416,7 @@ class ThemeMixin:
270
416
  link = QColor(120, 170, 255)
271
417
  linkv = QColor(180, 150, 255)
272
418
  hi = QColor(95, 145, 230)
273
- hitxt = QColor(255, 255, 255)
419
+ hitxt = QColor(20, 20, 20)
274
420
 
275
421
  # Core roles
276
422
  p.setColor(QPalette.ColorRole.Window, window)
@@ -300,7 +446,7 @@ class ThemeMixin:
300
446
  p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, dis)
301
447
  p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, QColor(58, 58, 58))
302
448
  p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Highlight, QColor(80, 80, 80))
303
- p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, QColor(210, 210, 210))
449
+ p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, QColor(20, 20, 20))
304
450
 
305
451
  return p
306
452