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.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +132 -61
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +340 -88
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +769 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/live_stacking.py +181 -73
- setiastro/saspro/multiscale_decomp.py +77 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +68 -0
- setiastro/saspro/ser_stacker.py +2245 -0
- setiastro/saspro/ser_stacker_dialog.py +1481 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1242 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +154 -25
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +853 -401
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +878 -131
- setiastro/saspro/subwindow.py +303 -74
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +128 -80
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() #
|
|
8234
|
+
sw.showNormal() # clears any "maximized" flag from previous active window
|
|
7986
8235
|
|
|
7987
|
-
#
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
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 (
|
|
8212
|
-
base_name = ""
|
|
8433
|
+
# 2) New name (normalized: NO decorators like 🔗■●◆▲▪▫•◼◻◾◽)
|
|
8213
8434
|
try:
|
|
8214
|
-
base_name =
|
|
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
|
-
|
|
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
|
-
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8381
|
-
|
|
8382
|
-
#
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
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
|