setiastrosuitepro 1.6.10__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.
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/narrowbandnormalization.png +0 -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/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/gui/main_window.py +218 -66
- 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/toolbar_mixin.py +132 -10
- 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 +66 -15
- setiastro/saspro/legacy/numba_utils.py +25 -48
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +0 -55
- 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 +35 -16
- setiastro/saspro/stacking_suite.py +332 -87
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +220 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.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
|
|
|
@@ -604,7 +604,10 @@ class AstroSuiteProMainWindow(
|
|
|
604
604
|
self.docman.documentAdded.connect(self._on_document_added)
|
|
605
605
|
self.mdi.viewStateDropped.connect(self._on_mdi_viewstate_drop)
|
|
606
606
|
self.mdi.linkViewDropped.connect(self._on_linkview_drop)
|
|
607
|
-
|
|
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
|
|
608
611
|
self.doc_manager.set_mdi_area(self.mdi)
|
|
609
612
|
# Coalesce undo/redo label refreshes
|
|
610
613
|
self._undo_redo_refresh_pending = False
|
|
@@ -2660,10 +2663,27 @@ class AstroSuiteProMainWindow(
|
|
|
2660
2663
|
return f"{name}{dims}"
|
|
2661
2664
|
|
|
2662
2665
|
def _update_explorer_item_for_doc(self, doc):
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
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
|
|
2667
2687
|
return
|
|
2668
2688
|
#-----------FUNCTIONS----------------
|
|
2669
2689
|
|
|
@@ -3888,6 +3908,19 @@ class AstroSuiteProMainWindow(
|
|
|
3888
3908
|
|
|
3889
3909
|
dlg.show()
|
|
3890
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
|
+
|
|
3891
3924
|
def _open_ppp_tool(self):
|
|
3892
3925
|
from setiastro.saspro.perfect_palette_picker import PerfectPalettePicker
|
|
3893
3926
|
w = PerfectPalettePicker(doc_manager=self.docman) # parent gives access to _spawn_subwindow_for
|
|
@@ -4239,6 +4272,14 @@ class AstroSuiteProMainWindow(
|
|
|
4239
4272
|
dlg.setWindowIcon(QIcon(livestacking_path))
|
|
4240
4273
|
dlg.show()
|
|
4241
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
|
+
|
|
4242
4283
|
def _open_stacking_suite(self):
|
|
4243
4284
|
# Reuse if we already have one
|
|
4244
4285
|
dlg = getattr(self, "_stacking_suite", None)
|
|
@@ -4282,6 +4323,115 @@ class AstroSuiteProMainWindow(
|
|
|
4282
4323
|
except Exception:
|
|
4283
4324
|
pass
|
|
4284
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
|
+
|
|
4285
4435
|
def _on_stackingsuite_relaunch(self, old_dir: str, new_dir: str):
|
|
4286
4436
|
# Optional: respond to dialog's relaunch request
|
|
4287
4437
|
try:
|
|
@@ -7871,6 +8021,43 @@ class AstroSuiteProMainWindow(
|
|
|
7871
8021
|
|
|
7872
8022
|
return t
|
|
7873
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)
|
|
8060
|
+
|
|
7874
8061
|
def _spawn_subwindow_for(self, doc, *, force_new: bool = False):
|
|
7875
8062
|
"""
|
|
7876
8063
|
Open a subwindow for `doc`. If one already exists and force_new=False,
|
|
@@ -8044,52 +8231,22 @@ class AstroSuiteProMainWindow(
|
|
|
8044
8231
|
target_h = max(200, target_h)
|
|
8045
8232
|
|
|
8046
8233
|
sw.resize(target_w, target_h)
|
|
8047
|
-
sw.showNormal() #
|
|
8234
|
+
sw.showNormal() # clears any "maximized" flag from previous active window
|
|
8048
8235
|
|
|
8049
|
-
#
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
active = self.mdi.activeSubWindow()
|
|
8057
|
-
if active and active.isVisible() and not (active.windowState() & Qt.WindowState.WindowMinimized):
|
|
8058
|
-
# Cascade from the active window
|
|
8059
|
-
geo = active.geometry()
|
|
8060
|
-
new_x = geo.x() + 30
|
|
8061
|
-
new_y = geo.y() + 30
|
|
8062
|
-
else:
|
|
8063
|
-
# Fallback: try to find the "last added" visible window to cascade from
|
|
8064
|
-
# (useful if active is None but windows exist)
|
|
8065
|
-
try:
|
|
8066
|
-
subs = [s for s in self.mdi.subWindowList() if s.isVisible() and s is not sw]
|
|
8067
|
-
if subs:
|
|
8068
|
-
# simplistic "last created" might be at end of list
|
|
8069
|
-
last = subs[-1]
|
|
8070
|
-
geo = last.geometry()
|
|
8071
|
-
new_x = geo.x() + 30
|
|
8072
|
-
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)
|
|
8073
8243
|
except Exception:
|
|
8074
8244
|
pass
|
|
8075
8245
|
|
|
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)
|
|
8083
|
-
|
|
8084
|
-
sw.move(new_x, new_y)
|
|
8085
|
-
|
|
8086
|
-
# ⌠removed the "fill MDI viewport" block - we *don't* want full-monitor first window
|
|
8087
|
-
|
|
8088
8246
|
# Show / activate
|
|
8089
8247
|
sw.show()
|
|
8090
8248
|
sw.raise_()
|
|
8091
8249
|
self.mdi.setActiveSubWindow(sw)
|
|
8092
|
-
# (no second setWindowTitle() here)
|
|
8093
8250
|
|
|
8094
8251
|
# Optional minimize/restore interceptor
|
|
8095
8252
|
if hasattr(self, "_minimize_interceptor"):
|
|
@@ -8473,26 +8630,21 @@ class AstroSuiteProMainWindow(
|
|
|
8473
8630
|
|
|
8474
8631
|
|
|
8475
8632
|
def _activate_or_open_from_explorer(self, item):
|
|
8476
|
-
doc = item.data(Qt.ItemDataRole.UserRole)
|
|
8477
|
-
|
|
8478
|
-
|
|
8479
|
-
#
|
|
8480
|
-
|
|
8481
|
-
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
|
|
8485
|
-
|
|
8486
|
-
|
|
8487
|
-
|
|
8488
|
-
|
|
8489
|
-
|
|
8490
|
-
|
|
8491
|
-
pass
|
|
8492
|
-
return
|
|
8493
|
-
|
|
8494
|
-
# 2) None exists -> open one
|
|
8495
|
-
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
|
|
8496
8648
|
|
|
8497
8649
|
def _set_linked_stretch_from_action(self, checked: bool):
|
|
8498
8650
|
# persist as the default for *new* views
|
|
@@ -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)
|