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.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +218 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +769 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +68 -0
  34. setiastro/saspro/ser_stacker.py +2245 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1242 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  51. {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
- for i in range(self.explorer.count()):
2664
- it = self.explorer.item(i)
2665
- if it.data(Qt.ItemDataRole.UserRole) is doc:
2666
- it.setText(self._format_explorer_title(doc))
2666
+ # Delegate to DockMixin implementation if present
2667
+ try:
2668
+ return super()._update_explorer_item_for_doc(doc)
2669
+ except Exception:
2670
+ pass
2671
+
2672
+ # Fallback: tree-safe implementation
2673
+ if not hasattr(self, "explorer") or self.explorer is None:
2674
+ return
2675
+ try:
2676
+ n = self.explorer.topLevelItemCount()
2677
+ except Exception:
2678
+ return
2679
+
2680
+ for i in range(n):
2681
+ it = self.explorer.topLevelItem(i)
2682
+ if it.data(0, Qt.ItemDataRole.UserRole) is doc:
2683
+ try:
2684
+ self._refresh_explorer_row(it, doc)
2685
+ except Exception:
2686
+ pass
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() # CRITICAL: clears any "maximized" flag from previous active window
8234
+ sw.showNormal() # clears any "maximized" flag from previous active window
8048
8235
 
8049
- # -------------------------------------------------------------------------
8050
- # Smart Cascade: Position relative to the *currently active* window
8051
- # (before we make the new one active).
8052
- # -------------------------------------------------------------------------
8053
- new_x, new_y = area.left(), area.top()
8054
-
8055
- # Get dominant/active window *before* we activate the new one
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
- base = self._normalize_base_doc(doc)
8478
-
8479
- # 1) Try to focus an existing view for this base
8480
- for sw in self.mdi.subWindowList():
8481
- w = sw.widget()
8482
- if getattr(w, "base_document", None) is base:
8483
- try:
8484
- sw.show(); w.show()
8485
- st = sw.windowState()
8486
- if st & Qt.WindowState.WindowMinimized:
8487
- sw.setWindowState(st & ~Qt.WindowState.WindowMinimized)
8488
- self.mdi.setActiveSubWindow(sw)
8489
- sw.raise_()
8490
- except Exception:
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
- self.explorer = QListWidget()
109
- # Enter/Return or single-activation: focus if open, else open
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
- # Double-click: same behavior
112
- self.explorer.itemDoubleClicked.connect(self._activate_or_open_from_explorer)
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(self.explorer)
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.count()):
345
- it = self.explorer.item(i)
346
- if it.data(Qt.ItemDataRole.UserRole) is base:
347
- # refresh text in case dims/name changed
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
- item = QListWidgetItem(self._format_explorer_title(base))
352
- item.setData(Qt.ItemDataRole.UserRole, base)
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
- item.setToolTip(fp)
356
- self.explorer.addItem(item)
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(base))
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.count()):
370
- it = self.explorer.item(i)
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.takeItem(i)
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)