setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__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.

Files changed (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.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
- 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
 
@@ -243,22 +303,35 @@ class DockMixin:
243
303
  self.settings.setValue("ui/resource_monitor_visible", checked)
244
304
 
245
305
  def _update_monitor_position(self):
246
- """Snap monitor to bottom-right corner."""
306
+ """Snap monitor to bottom-right corner or restore saved position."""
247
307
  if hasattr(self, 'resource_monitor') and self.resource_monitor:
248
308
  from PyQt6.QtCore import QPoint
249
- m = 5 # margin
250
- # Position relative to the main window geometry
251
- w = self.resource_monitor.width()
252
- h = self.resource_monitor.height()
253
309
 
254
- # Anchor to bottom-right of the window
255
- x = self.width() - w - m
256
- y = self.height() - h - m
310
+ # Check for saved position first
311
+ saved_x = self.settings.value("ui/resource_monitor_pos_x", type=int)
312
+ saved_y = self.settings.value("ui/resource_monitor_pos_y", type=int)
257
313
 
258
- # Map local MainWindow coordinates to Global Screen coordinates
259
- # This is required because resource_monitor is a Top-Level Window (for transparency)
260
- global_pos = self.mapToGlobal(QPoint(x, y))
261
- self.resource_monitor.move(global_pos)
314
+ if saved_x != 0 and saved_y != 0: # Basic validity check (0,0 is unlikely to be desired but also default if missing)
315
+ # Actually 0,0 is valid but type=int returns 0 if missing.
316
+ # Let's check string existence to be safer or just accept 0 if set.
317
+ # Checking existence via `contains` is better but value() logic is ok for now.
318
+ if self.settings.contains("ui/resource_monitor_pos_x"):
319
+ self.resource_monitor.move(saved_x, saved_y)
320
+ self.resource_monitor.raise_()
321
+ return
322
+
323
+ m = 5 # margin
324
+
325
+ screen = self.screen()
326
+ geom = screen.availableGeometry()
327
+
328
+ mw = self.resource_monitor.width()
329
+ mh = self.resource_monitor.height()
330
+
331
+ x = geom.x() + geom.width() - mw - m
332
+ y = geom.y() + geom.height() - mh - m
333
+
334
+ self.resource_monitor.move(x, y)
262
335
  self.resource_monitor.raise_()
263
336
 
264
337
  # We need to hook resizeEvent to call _update_monitor_position.
@@ -290,12 +363,12 @@ class DockMixin:
290
363
 
291
364
  # Friendly ordering for common ones; others follow alphabetically.
292
365
  order_hint = {
293
- "Explorer": 10,
294
- "Console / Status": 20,
295
- "Header Viewer": 30,
296
- "Layers": 40,
297
- "Window Shelf": 50,
298
- "Command Search": 60,
366
+ self.tr("Explorer"): 10,
367
+ self.tr("Console / Status"): 20,
368
+ self.tr("Header Viewer"): 30,
369
+ self.tr("Layers"): 40,
370
+ self.tr("Window Shelf"): 50,
371
+ self.tr("Command Search"): 60,
299
372
  }
300
373
 
301
374
  # Add special action for overlay monitor
@@ -328,35 +401,196 @@ class DockMixin:
328
401
  base = self._normalize_base_doc(doc)
329
402
 
330
403
  # de-dupe by identity on base
331
- for i in range(self.explorer.count()):
332
- it = self.explorer.item(i)
333
- if it.data(Qt.ItemDataRole.UserRole) is base:
334
- # refresh text in case dims/name changed
335
- 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)
336
408
  return
337
409
 
338
- item = QListWidgetItem(self._format_explorer_title(base))
339
- 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
+
340
418
  fp = (base.metadata or {}).get("file_path")
341
419
  if fp:
342
- item.setToolTip(fp)
343
- self.explorer.addItem(item)
420
+ it.setToolTip(0, fp)
421
+
422
+ self.explorer.addTopLevelItem(it)
344
423
 
345
424
  # keep row label in sync with edits/resizes/renames
346
425
  try:
347
- 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))
348
427
  except Exception:
349
428
  pass
350
429
 
430
+
351
431
  def _remove_doc_from_explorer(self, doc):
352
- """
353
- Remove either the exact doc or its base (handles ROI proxies).
354
- """
355
432
  base = self._normalize_base_doc(doc)
356
- for i in range(self.explorer.count()):
357
- it = self.explorer.item(i)
358
- 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)
359
436
  if d is doc or d is base:
360
- self.explorer.takeItem(i)
437
+ self.explorer.takeTopLevelItem(i)
361
438
  break
362
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)
@@ -51,12 +51,10 @@ except ImportError:
51
51
  return cv2.resize(arr, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
52
52
 
53
53
 
54
- # Try to import WCS update function
55
- try:
56
- from setiastro.saspro.wcs_utils import update_wcs_after_crop
57
- except ImportError:
58
- update_wcs_after_crop = None
54
+ from setiastro.saspro.wcs_update import update_wcs_after_crop
59
55
 
56
+ import cv2
57
+ import math
60
58
 
61
59
  if TYPE_CHECKING:
62
60
  pass
@@ -209,6 +207,44 @@ class GeometryMixin:
209
207
  except Exception as e:
210
208
  QMessageBox.critical(self, "Rotate 180°", str(e))
211
209
 
210
+ def _exec_geom_rot_any(self):
211
+ sw = self.mdi.activeSubWindow() if hasattr(self, "mdi") else None
212
+ view = sw.widget() if sw else None
213
+ doc = getattr(view, "document", None)
214
+ if doc is None or getattr(doc, "image", None) is None:
215
+ QMessageBox.information(self, self.tr("Rotate..."), self.tr("Active view has no image."))
216
+ return
217
+
218
+ if cv2 is None:
219
+ QMessageBox.warning(self, self.tr("Rotate..."), self.tr("OpenCV (cv2) is required for arbitrary rotation."))
220
+ return
221
+
222
+ dlg = QInputDialog(self)
223
+ dlg.setWindowTitle(self.tr("Rotate..."))
224
+ dlg.setLabelText(self.tr("Angle in degrees (positive = CCW):"))
225
+ dlg.setInputMode(QInputDialog.InputMode.DoubleInput)
226
+ dlg.setDoubleRange(-360.0, 360.0)
227
+ dlg.setDoubleDecimals(2)
228
+ dlg.setDoubleValue(0.0)
229
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
230
+
231
+ try:
232
+ from setiastro.saspro.resources import rotatearbitrary_path
233
+ dlg.setWindowIcon(QIcon(rotatearbitrary_path))
234
+ except Exception:
235
+ pass
236
+
237
+ if dlg.exec() != QDialog.DialogCode.Accepted:
238
+ return
239
+
240
+ angle = float(dlg.doubleValue())
241
+ try:
242
+ self._apply_geom_rot_any_to_doc(doc, angle_deg=angle)
243
+ self._log(f"Rotate ({angle:g}°) applied to active view")
244
+ except Exception as e:
245
+ QMessageBox.critical(self, self.tr("Rotate..."), str(e))
246
+
247
+
212
248
  def _exec_geom_rescale(self):
213
249
  """Execute rescale operation on active view with dialog."""
214
250
  sw = self.mdi.activeSubWindow() if hasattr(self, "mdi") else None
@@ -334,6 +370,70 @@ class GeometryMixin:
334
370
 
335
371
  self._apply_geom_with_wcs(doc, out, M_src_to_dst=M, step_name="Rotate 180°")
336
372
 
373
+ def _apply_geom_rot_any_to_doc(self, doc, *, angle_deg: float):
374
+ if cv2 is None:
375
+ raise RuntimeError("cv2 is required for arbitrary rotation")
376
+
377
+ src = np.asarray(doc.image, dtype=np.float32, order="C")
378
+ h, w = src.shape[:2]
379
+
380
+ # Rotation about center
381
+ cx = (w - 1) * 0.5
382
+ cy = (h - 1) * 0.5
383
+
384
+ # OpenCV uses CCW degrees
385
+ A2 = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # 2x3
386
+
387
+ # Convert to 3x3
388
+ M = np.array([
389
+ [A2[0,0], A2[0,1], A2[0,2]],
390
+ [A2[1,0], A2[1,1], A2[1,2]],
391
+ [0.0, 0.0, 1.0 ],
392
+ ], dtype=np.float32)
393
+
394
+ # Compute output bounds by rotating the four corners
395
+ corners = np.array([
396
+ [0.0, 0.0, 1.0],
397
+ [w - 1.0, 0.0, 1.0],
398
+ [w - 1.0, h - 1.0, 1.0],
399
+ [0.0, h - 1.0, 1.0],
400
+ ], dtype=np.float32).T # 3x4
401
+
402
+ rc = (M @ corners) # 3x4
403
+ xs = rc[0, :]
404
+ ys = rc[1, :]
405
+
406
+ min_x = float(xs.min())
407
+ max_x = float(xs.max())
408
+ min_y = float(ys.min())
409
+ max_y = float(ys.max())
410
+
411
+ out_w = int(math.ceil(max_x - min_x + 1.0))
412
+ out_h = int(math.ceil(max_y - min_y + 1.0))
413
+ if out_w <= 0 or out_h <= 0:
414
+ raise RuntimeError("Invalid output size after rotation")
415
+
416
+ # Shift so that min corner maps to (0,0)
417
+ T = np.array([
418
+ [1.0, 0.0, -min_x],
419
+ [0.0, 1.0, -min_y],
420
+ [0.0, 0.0, 1.0],
421
+ ], dtype=np.float32)
422
+
423
+ M = (T @ M).astype(np.float32) # final src->dst 3x3
424
+
425
+ # Warp
426
+ # cv2.warpPerspective expects (W,H)
427
+ flags = cv2.INTER_LANCZOS4
428
+ if src.ndim == 2:
429
+ out = cv2.warpPerspective(src, M, (out_w, out_h), flags=flags)
430
+ else:
431
+ # warpPerspective works on multi-channel too
432
+ out = cv2.warpPerspective(src, M, (out_w, out_h), flags=flags)
433
+
434
+ self._apply_geom_with_wcs(doc, out, M_src_to_dst=M, step_name=f"Rotate ({angle_deg:g}°)")
435
+
436
+
337
437
  def _apply_geom_rescale_to_doc(self, doc, *, factor: float):
338
438
  """Apply rescale to document with WCS update."""
339
439
  factor = float(max(0.1, min(10.0, factor)))
@@ -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
@@ -185,6 +186,7 @@ class MenuMixin:
185
186
  m_geom.addAction(self.act_geom_rot_cw)
186
187
  m_geom.addAction(self.act_geom_rot_ccw)
187
188
  m_geom.addAction(self.act_geom_rot_180)
189
+ m_geom.addAction(self.act_geom_rot_any)
188
190
  m_geom.addSeparator()
189
191
  m_geom.addAction(self.act_geom_rescale)
190
192
  m_geom.addSeparator()
@@ -228,6 +230,7 @@ class MenuMixin:
228
230
 
229
231
 
230
232
  m_header = mb.addMenu(self.tr("&Header Mods && Misc"))
233
+ m_header.addAction(self.act_acv_exporter)
231
234
  m_header.addAction(self.act_astrobin_exporter)
232
235
  m_header.addAction(self.act_batch_convert)
233
236
  m_header.addAction(self.act_batch_renamer)
@@ -265,6 +268,14 @@ class MenuMixin:
265
268
  m_view.addAction(self.act_tile_grid)
266
269
  m_view.addSeparator()
267
270
 
271
+ # NEW: Minimize All Views
272
+ self.act_minimize_all_views = QAction(self.tr("Minimize All Views"), self)
273
+ self.act_minimize_all_views.setShortcut(QKeySequence("Ctrl+Shift+M"))
274
+ self.act_minimize_all_views.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
275
+ self.act_minimize_all_views.triggered.connect(self._minimize_all_views)
276
+ m_view.addAction(self.act_minimize_all_views)
277
+
278
+ m_view.addSeparator()
268
279
 
269
280
  # a button that shows current group & opens a drop-down
270
281
  self._link_btn = QToolButton(self)
@@ -387,3 +398,19 @@ class MenuMixin:
387
398
  if sub is not None:
388
399
  yield from self._iter_menu_actions(sub)
389
400
 
401
+ def _minimize_all_views(self):
402
+ mdi = getattr(self, "mdi", None)
403
+ if mdi is None:
404
+ return
405
+
406
+ try:
407
+ for sw in mdi.subWindowList():
408
+ try:
409
+ if not sw.isVisible():
410
+ continue
411
+ # Minimize each MDI child
412
+ sw.showMinimized()
413
+ except Exception:
414
+ pass
415
+ except Exception:
416
+ pass