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
|
@@ -12,13 +12,43 @@ from PyQt6.QtCore import Qt, QTimer
|
|
|
12
12
|
from PyQt6.QtWidgets import (
|
|
13
13
|
QDockWidget, QPlainTextEdit, QTreeWidget, QTreeWidgetItem,
|
|
14
14
|
QVBoxLayout, QWidget, QTextEdit, QListWidget, QListWidgetItem,
|
|
15
|
-
QAbstractItemView, QApplication
|
|
15
|
+
QAbstractItemView, QApplication, QLineEdit, QMenu
|
|
16
16
|
)
|
|
17
|
-
from PyQt6.QtGui import QTextCursor, QAction
|
|
17
|
+
from PyQt6.QtGui import QTextCursor, QAction, QGuiApplication
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from PyQt6.QtWidgets import QAction
|
|
21
21
|
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
GLYPHS = "■●◆▲▪▫•◼◻◾◽🔗"
|
|
25
|
+
|
|
26
|
+
def _strip_ui_decorations(text: str) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Strip UI-only decorations from titles:
|
|
29
|
+
- Qt mnemonics (&)
|
|
30
|
+
- link badges like "[LINK]"
|
|
31
|
+
- your glyph badges
|
|
32
|
+
- file extension (optional, but nice for Explorer)
|
|
33
|
+
"""
|
|
34
|
+
if not text:
|
|
35
|
+
return ""
|
|
36
|
+
s = str(text)
|
|
37
|
+
|
|
38
|
+
# remove mnemonics
|
|
39
|
+
s = s.replace("&", "")
|
|
40
|
+
|
|
41
|
+
# remove common prefixes/badges
|
|
42
|
+
s = s.replace("[LINK]", "").strip()
|
|
43
|
+
|
|
44
|
+
# remove glyph badges
|
|
45
|
+
s = s.translate({ord(ch): None for ch in GLYPHS})
|
|
46
|
+
|
|
47
|
+
# collapse whitespace
|
|
48
|
+
s = " ".join(s.split())
|
|
49
|
+
|
|
50
|
+
return s
|
|
51
|
+
|
|
22
52
|
|
|
23
53
|
class DockMixin:
|
|
24
54
|
"""
|
|
@@ -105,14 +135,44 @@ class DockMixin:
|
|
|
105
135
|
self._view_panels_menu.removeAction(action)
|
|
106
136
|
|
|
107
137
|
def _init_explorer_dock(self):
|
|
108
|
-
|
|
109
|
-
|
|
138
|
+
host = QWidget(self)
|
|
139
|
+
lay = QVBoxLayout(host)
|
|
140
|
+
lay.setContentsMargins(4, 4, 4, 4)
|
|
141
|
+
lay.setSpacing(4)
|
|
142
|
+
|
|
143
|
+
# Optional filter box (super useful)
|
|
144
|
+
self.explorer_filter = QLineEdit(host)
|
|
145
|
+
self.explorer_filter.setPlaceholderText(self.tr("Filter open documents…"))
|
|
146
|
+
self.explorer_filter.textChanged.connect(self._explorer_apply_filter)
|
|
147
|
+
lay.addWidget(self.explorer_filter)
|
|
148
|
+
|
|
149
|
+
self.explorer = QTreeWidget(host)
|
|
150
|
+
self.explorer.setObjectName("ExplorerTree")
|
|
151
|
+
self.explorer.setColumnCount(3)
|
|
152
|
+
self.explorer.setHeaderLabels([self.tr("Document"), self.tr("Dims"), self.tr("Type")])
|
|
153
|
+
|
|
154
|
+
# Sorting
|
|
155
|
+
self.explorer.setSortingEnabled(True)
|
|
156
|
+
self.explorer.header().setSortIndicatorShown(True)
|
|
157
|
+
self.explorer.sortByColumn(0, Qt.SortOrder.AscendingOrder)
|
|
158
|
+
|
|
159
|
+
# Selection/activation behavior
|
|
160
|
+
self.explorer.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
110
161
|
self.explorer.itemActivated.connect(self._activate_or_open_from_explorer)
|
|
111
|
-
|
|
112
|
-
|
|
162
|
+
|
|
163
|
+
# Inline rename support
|
|
164
|
+
self.explorer.setEditTriggers(
|
|
165
|
+
QAbstractItemView.EditTrigger.EditKeyPressed |
|
|
166
|
+
QAbstractItemView.EditTrigger.SelectedClicked
|
|
167
|
+
)
|
|
168
|
+
self.explorer.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
169
|
+
self.explorer.customContextMenuRequested.connect(self._on_explorer_context_menu)
|
|
170
|
+
self.explorer.itemChanged.connect(self._on_explorer_item_changed)
|
|
171
|
+
|
|
172
|
+
lay.addWidget(self.explorer)
|
|
113
173
|
|
|
114
174
|
dock = QDockWidget(self.tr("Explorer"), self)
|
|
115
|
-
dock.setWidget(
|
|
175
|
+
dock.setWidget(host)
|
|
116
176
|
dock.setObjectName("ExplorerDock")
|
|
117
177
|
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock)
|
|
118
178
|
|
|
@@ -341,35 +401,196 @@ class DockMixin:
|
|
|
341
401
|
base = self._normalize_base_doc(doc)
|
|
342
402
|
|
|
343
403
|
# de-dupe by identity on base
|
|
344
|
-
for i in range(self.explorer.
|
|
345
|
-
it = self.explorer.
|
|
346
|
-
if it.data(Qt.ItemDataRole.UserRole) is base:
|
|
347
|
-
|
|
348
|
-
it.setText(self._format_explorer_title(base))
|
|
404
|
+
for i in range(self.explorer.topLevelItemCount()):
|
|
405
|
+
it = self.explorer.topLevelItem(i)
|
|
406
|
+
if it.data(0, Qt.ItemDataRole.UserRole) is base:
|
|
407
|
+
self._refresh_explorer_row(it, base)
|
|
349
408
|
return
|
|
350
409
|
|
|
351
|
-
|
|
352
|
-
|
|
410
|
+
it = QTreeWidgetItem()
|
|
411
|
+
it.setData(0, Qt.ItemDataRole.UserRole, base)
|
|
412
|
+
|
|
413
|
+
# Make name editable; other columns read-only
|
|
414
|
+
it.setFlags(it.flags() | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
|
415
|
+
|
|
416
|
+
self._refresh_explorer_row(it, base)
|
|
417
|
+
|
|
353
418
|
fp = (base.metadata or {}).get("file_path")
|
|
354
419
|
if fp:
|
|
355
|
-
|
|
356
|
-
|
|
420
|
+
it.setToolTip(0, fp)
|
|
421
|
+
|
|
422
|
+
self.explorer.addTopLevelItem(it)
|
|
357
423
|
|
|
358
424
|
# keep row label in sync with edits/resizes/renames
|
|
359
425
|
try:
|
|
360
|
-
base.changed.connect(lambda *_: self._update_explorer_item_for_doc(
|
|
426
|
+
base.changed.connect(lambda *_, d=base: self._update_explorer_item_for_doc(d))
|
|
361
427
|
except Exception:
|
|
362
428
|
pass
|
|
363
429
|
|
|
430
|
+
|
|
364
431
|
def _remove_doc_from_explorer(self, doc):
|
|
365
|
-
"""
|
|
366
|
-
Remove either the exact doc or its base (handles ROI proxies).
|
|
367
|
-
"""
|
|
368
432
|
base = self._normalize_base_doc(doc)
|
|
369
|
-
for i in range(self.explorer.
|
|
370
|
-
it = self.explorer.
|
|
371
|
-
d = it.data(Qt.ItemDataRole.UserRole)
|
|
433
|
+
for i in range(self.explorer.topLevelItemCount()):
|
|
434
|
+
it = self.explorer.topLevelItem(i)
|
|
435
|
+
d = it.data(0, Qt.ItemDataRole.UserRole)
|
|
372
436
|
if d is doc or d is base:
|
|
373
|
-
self.explorer.
|
|
437
|
+
self.explorer.takeTopLevelItem(i)
|
|
374
438
|
break
|
|
375
439
|
|
|
440
|
+
|
|
441
|
+
def _update_explorer_item_for_doc(self, doc):
|
|
442
|
+
for i in range(self.explorer.topLevelItemCount()):
|
|
443
|
+
it = self.explorer.topLevelItem(i)
|
|
444
|
+
if it.data(0, Qt.ItemDataRole.UserRole) is doc:
|
|
445
|
+
self._refresh_explorer_row(it, doc)
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
def _refresh_explorer_row(self, item, doc):
|
|
449
|
+
# Column 0: display name (NO glyph decorations)
|
|
450
|
+
name = _strip_ui_decorations(doc.display_name() or "Untitled")
|
|
451
|
+
|
|
452
|
+
name_no_ext, _ext = os.path.splitext(name)
|
|
453
|
+
if name_no_ext:
|
|
454
|
+
name = name_no_ext
|
|
455
|
+
|
|
456
|
+
item.setText(0, name)
|
|
457
|
+
|
|
458
|
+
# Column 1: dims
|
|
459
|
+
dims = ""
|
|
460
|
+
try:
|
|
461
|
+
import numpy as np
|
|
462
|
+
arr = getattr(doc, "image", None)
|
|
463
|
+
if isinstance(arr, np.ndarray) and arr.size:
|
|
464
|
+
h, w = arr.shape[:2]
|
|
465
|
+
c = arr.shape[2] if arr.ndim == 3 else 1
|
|
466
|
+
dims = f"{h}×{w}×{c}"
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
item.setText(1, dims)
|
|
470
|
+
|
|
471
|
+
# Column 2: type/bit-depth (whatever you have available)
|
|
472
|
+
md = (doc.metadata or {})
|
|
473
|
+
bit = md.get("bit_depth") or md.get("dtype") or ""
|
|
474
|
+
kind = md.get("format") or md.get("doc_type") or ""
|
|
475
|
+
t = " / ".join([s for s in (str(kind), str(bit)) if s and s != "None"])
|
|
476
|
+
item.setText(2, t)
|
|
477
|
+
|
|
478
|
+
def _on_explorer_item_changed(self, item, col: int):
|
|
479
|
+
if col != 0:
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
doc = item.data(0, Qt.ItemDataRole.UserRole)
|
|
483
|
+
if doc is None:
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
new_name = (item.text(0) or "").strip()
|
|
487
|
+
if not new_name:
|
|
488
|
+
# revert to current doc name
|
|
489
|
+
self._refresh_explorer_row(item, doc)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
# Avoid infinite loops: only apply if changed
|
|
493
|
+
cur = _strip_ui_decorations(doc.display_name() or "Untitled")
|
|
494
|
+
cur_no_ext, _ = os.path.splitext(cur)
|
|
495
|
+
cur = cur_no_ext or cur
|
|
496
|
+
if new_name == cur:
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
doc.metadata["display_name"] = new_name
|
|
501
|
+
except Exception:
|
|
502
|
+
# if metadata missing or immutable, revert
|
|
503
|
+
self._refresh_explorer_row(item, doc)
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
doc.changed.emit()
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
def _on_explorer_context_menu(self, pos):
|
|
512
|
+
it = self.explorer.itemAt(pos)
|
|
513
|
+
if it is None:
|
|
514
|
+
return
|
|
515
|
+
doc = it.data(0, Qt.ItemDataRole.UserRole)
|
|
516
|
+
if doc is None:
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
menu = QMenu(self.explorer)
|
|
520
|
+
a_rename = menu.addAction(self.tr("Rename Document…"))
|
|
521
|
+
a_close = menu.addAction(self.tr("Close Document"))
|
|
522
|
+
menu.addSeparator()
|
|
523
|
+
a_copy_path = menu.addAction(self.tr("Copy File Path"))
|
|
524
|
+
a_reveal = menu.addAction(self.tr("Reveal in File Manager"))
|
|
525
|
+
menu.addSeparator()
|
|
526
|
+
a_send_shelf = menu.addAction(self.tr("Send View to Shelf")) # acts on active view for this doc
|
|
527
|
+
|
|
528
|
+
act = menu.exec(self.explorer.viewport().mapToGlobal(pos))
|
|
529
|
+
if act == a_rename:
|
|
530
|
+
# Start inline editing
|
|
531
|
+
self.explorer.editItem(it, 0)
|
|
532
|
+
|
|
533
|
+
elif act == a_close:
|
|
534
|
+
# close only if no other subwindows show it: you already do that in _on_view_about_to_close,
|
|
535
|
+
# but Explorer close is explicit; just close all views of this doc then docman.close_document.
|
|
536
|
+
try:
|
|
537
|
+
self._close_all_views_for_doc(doc)
|
|
538
|
+
except Exception:
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
elif act == a_copy_path:
|
|
542
|
+
fp = (doc.metadata or {}).get("file_path", "")
|
|
543
|
+
if fp:
|
|
544
|
+
QGuiApplication.clipboard().setText(fp)
|
|
545
|
+
|
|
546
|
+
elif act == a_reveal:
|
|
547
|
+
fp = (doc.metadata or {}).get("file_path", "")
|
|
548
|
+
if fp:
|
|
549
|
+
self._reveal_in_file_manager(fp)
|
|
550
|
+
|
|
551
|
+
elif act == a_send_shelf:
|
|
552
|
+
sw = self._find_subwindow_for_doc(doc)
|
|
553
|
+
if sw and hasattr(sw.widget(), "_send_to_shelf"):
|
|
554
|
+
try:
|
|
555
|
+
sw.widget()._send_to_shelf()
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
|
|
559
|
+
def _close_all_views_for_doc(self, doc):
|
|
560
|
+
base = self._normalize_base_doc(doc)
|
|
561
|
+
subs = list(self.mdi.subWindowList())
|
|
562
|
+
for sw in subs:
|
|
563
|
+
w = sw.widget()
|
|
564
|
+
if getattr(w, "base_document", None) is base:
|
|
565
|
+
try:
|
|
566
|
+
sw.close()
|
|
567
|
+
except Exception:
|
|
568
|
+
pass
|
|
569
|
+
# If none left (or even if close failed), try docman close defensively
|
|
570
|
+
try:
|
|
571
|
+
self.docman.close_document(base)
|
|
572
|
+
except Exception:
|
|
573
|
+
pass
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _reveal_in_file_manager(self, path: str):
|
|
577
|
+
import sys, os, subprocess
|
|
578
|
+
try:
|
|
579
|
+
if sys.platform.startswith("win"):
|
|
580
|
+
subprocess.Popen(["explorer", "/select,", os.path.normpath(path)])
|
|
581
|
+
elif sys.platform == "darwin":
|
|
582
|
+
subprocess.Popen(["open", "-R", path])
|
|
583
|
+
else:
|
|
584
|
+
# best-effort on Linux
|
|
585
|
+
subprocess.Popen(["xdg-open", os.path.dirname(path)])
|
|
586
|
+
except Exception:
|
|
587
|
+
pass
|
|
588
|
+
|
|
589
|
+
def _explorer_apply_filter(self, text: str):
|
|
590
|
+
t = (text or "").strip().lower()
|
|
591
|
+
for i in range(self.explorer.topLevelItemCount()):
|
|
592
|
+
it = self.explorer.topLevelItem(i)
|
|
593
|
+
name = (it.text(0) or "").lower()
|
|
594
|
+
fp = (it.toolTip(0) or "").lower()
|
|
595
|
+
hide = bool(t) and (t not in name) and (t not in fp)
|
|
596
|
+
it.setHidden(hide)
|
|
@@ -7,7 +7,7 @@ import os
|
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from PyQt6.QtCore import Qt
|
|
10
|
-
from PyQt6.QtWidgets import QFileDialog, QMessageBox, QProgressDialog
|
|
10
|
+
from PyQt6.QtWidgets import QFileDialog, QMessageBox, QProgressDialog, QApplication
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
13
|
pass
|
|
@@ -85,7 +85,6 @@ class FileMixin:
|
|
|
85
85
|
self._save_recent_lists()
|
|
86
86
|
if hasattr(self, "_rebuild_recent_menus"):
|
|
87
87
|
self._rebuild_recent_menus()
|
|
88
|
-
# Extracted FILE methods
|
|
89
88
|
|
|
90
89
|
def open_files(self):
|
|
91
90
|
# One-stop "All Supported" plus focused groups the user can switch to
|
|
@@ -114,21 +113,41 @@ class FileMixin:
|
|
|
114
113
|
except Exception:
|
|
115
114
|
pass
|
|
116
115
|
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
116
|
+
# ---- BEGIN batch open (stable placement) ----
|
|
117
|
+
try:
|
|
118
|
+
self._mdi_begin_open_batch(mode="cascade")
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
123
|
+
try:
|
|
124
|
+
# open each path (doc_manager should emit documentAdded; no manual spawn)
|
|
125
|
+
for p in paths:
|
|
125
126
|
try:
|
|
126
|
-
|
|
127
|
-
self.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
_ = self.docman.open_path(p) # emits documentAdded; spawn will happen
|
|
128
|
+
self._log(f"Opened: {p}")
|
|
129
|
+
self._add_recent_image(p) # track MRU
|
|
130
|
+
|
|
131
|
+
# Increment statistics
|
|
132
|
+
try:
|
|
133
|
+
count = self.settings.value("stats/opened_images_count", 0, type=int)
|
|
134
|
+
self.settings.setValue("stats/opened_images_count", count + 1)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# Let Qt paint newly spawned subwindows as we go
|
|
139
|
+
QApplication.processEvents()
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
QMessageBox.warning(self, self.tr("Open failed"), f"{p}\n\n{e}")
|
|
143
|
+
QApplication.processEvents()
|
|
144
|
+
finally:
|
|
145
|
+
QApplication.restoreOverrideCursor()
|
|
146
|
+
try:
|
|
147
|
+
self._mdi_end_open_batch()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
132
151
|
|
|
133
152
|
def save_active(self):
|
|
134
153
|
from setiastro.saspro.main_helpers import (
|
|
@@ -6,8 +6,9 @@ from __future__ import annotations
|
|
|
6
6
|
import os
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
|
-
from PyQt6.QtGui import QAction
|
|
9
|
+
from PyQt6.QtGui import QAction, QKeySequence
|
|
10
10
|
from PyQt6.QtWidgets import QMenu, QToolButton, QWidgetAction
|
|
11
|
+
from PyQt6.QtCore import Qt
|
|
11
12
|
|
|
12
13
|
if TYPE_CHECKING:
|
|
13
14
|
pass
|
|
@@ -120,6 +121,9 @@ class MenuMixin:
|
|
|
120
121
|
m_edit = mb.addMenu(self.tr("&Edit"))
|
|
121
122
|
m_edit.addAction(self.act_undo)
|
|
122
123
|
m_edit.addAction(self.act_redo)
|
|
124
|
+
m_edit.addSeparator()
|
|
125
|
+
m_edit.addAction(self.act_mono_to_rgb)
|
|
126
|
+
|
|
123
127
|
|
|
124
128
|
# Functions
|
|
125
129
|
m_fn = mb.addMenu(self.tr("&Functions"))
|
|
@@ -169,6 +173,7 @@ class MenuMixin:
|
|
|
169
173
|
m_tools.addAction(self.act_freqsep)
|
|
170
174
|
m_tools.addAction(self.act_image_combine)
|
|
171
175
|
m_tools.addAction(self.act_multiscale_decomp)
|
|
176
|
+
m_tools.addAction(self.act_narrowband_normalization)
|
|
172
177
|
m_tools.addAction(self.act_nbtorgb)
|
|
173
178
|
m_tools.addAction(self.act_ppp)
|
|
174
179
|
m_tools.addAction(self.act_selective_color)
|
|
@@ -199,6 +204,7 @@ class MenuMixin:
|
|
|
199
204
|
m_star.addAction(self.act_isophote)
|
|
200
205
|
m_star.addAction(self.act_live_stacking)
|
|
201
206
|
m_star.addAction(self.act_mosaic_master)
|
|
207
|
+
m_star.addAction(self.act_planetary_stacker)
|
|
202
208
|
m_star.addAction(self.act_plate_solve)
|
|
203
209
|
m_star.addAction(self.act_psf_viewer)
|
|
204
210
|
m_star.addAction(self.act_rgb_align)
|
|
@@ -267,6 +273,14 @@ class MenuMixin:
|
|
|
267
273
|
m_view.addAction(self.act_tile_grid)
|
|
268
274
|
m_view.addSeparator()
|
|
269
275
|
|
|
276
|
+
# NEW: Minimize All Views
|
|
277
|
+
self.act_minimize_all_views = QAction(self.tr("Minimize All Views"), self)
|
|
278
|
+
self.act_minimize_all_views.setShortcut(QKeySequence("Ctrl+Shift+M"))
|
|
279
|
+
self.act_minimize_all_views.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
|
|
280
|
+
self.act_minimize_all_views.triggered.connect(self._minimize_all_views)
|
|
281
|
+
m_view.addAction(self.act_minimize_all_views)
|
|
282
|
+
|
|
283
|
+
m_view.addSeparator()
|
|
270
284
|
|
|
271
285
|
# a button that shows current group & opens a drop-down
|
|
272
286
|
self._link_btn = QToolButton(self)
|
|
@@ -389,3 +403,19 @@ class MenuMixin:
|
|
|
389
403
|
if sub is not None:
|
|
390
404
|
yield from self._iter_menu_actions(sub)
|
|
391
405
|
|
|
406
|
+
def _minimize_all_views(self):
|
|
407
|
+
mdi = getattr(self, "mdi", None)
|
|
408
|
+
if mdi is None:
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
for sw in mdi.subWindowList():
|
|
413
|
+
try:
|
|
414
|
+
if not sw.isVisible():
|
|
415
|
+
continue
|
|
416
|
+
# Minimize each MDI child
|
|
417
|
+
sw.showMinimized()
|
|
418
|
+
except Exception:
|
|
419
|
+
pass
|
|
420
|
+
except Exception:
|
|
421
|
+
pass
|
|
@@ -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"
|
|
146
|
-
active = "#242424"
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
176
|
+
base = "#eaeaea"
|
|
177
|
+
active = "#ffffff"
|
|
178
|
+
fg = "#141414"
|
|
156
179
|
|
|
180
|
+
# style *our* titlebar only
|
|
157
181
|
self.mdi.setStyleSheet(f"""
|
|
158
|
-
|
|
159
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
449
|
+
p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, QColor(20, 20, 20))
|
|
304
450
|
|
|
305
451
|
return p
|
|
306
452
|
|