setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.10__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 (112) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/first_quarter.png +0 -0
  6. setiastro/images/full_moon.png +0 -0
  7. setiastro/images/graxpert.svg +19 -0
  8. setiastro/images/last_quarter.png +0 -0
  9. setiastro/images/linearfit.svg +32 -0
  10. setiastro/images/new_moon.png +0 -0
  11. setiastro/images/pixelmath.svg +42 -0
  12. setiastro/images/waning_crescent_1.png +0 -0
  13. setiastro/images/waning_crescent_2.png +0 -0
  14. setiastro/images/waning_crescent_3.png +0 -0
  15. setiastro/images/waning_crescent_4.png +0 -0
  16. setiastro/images/waning_crescent_5.png +0 -0
  17. setiastro/images/waning_gibbous_1.png +0 -0
  18. setiastro/images/waning_gibbous_2.png +0 -0
  19. setiastro/images/waning_gibbous_3.png +0 -0
  20. setiastro/images/waning_gibbous_4.png +0 -0
  21. setiastro/images/waning_gibbous_5.png +0 -0
  22. setiastro/images/waxing_crescent_1.png +0 -0
  23. setiastro/images/waxing_crescent_2.png +0 -0
  24. setiastro/images/waxing_crescent_3.png +0 -0
  25. setiastro/images/waxing_crescent_4.png +0 -0
  26. setiastro/images/waxing_crescent_5.png +0 -0
  27. setiastro/images/waxing_gibbous_1.png +0 -0
  28. setiastro/images/waxing_gibbous_2.png +0 -0
  29. setiastro/images/waxing_gibbous_3.png +0 -0
  30. setiastro/images/waxing_gibbous_4.png +0 -0
  31. setiastro/images/waxing_gibbous_5.png +0 -0
  32. setiastro/qml/ResourceMonitor.qml +84 -82
  33. setiastro/saspro/__main__.py +19 -0
  34. setiastro/saspro/_generated/build_info.py +2 -2
  35. setiastro/saspro/abe.py +37 -4
  36. setiastro/saspro/aberration_ai.py +237 -21
  37. setiastro/saspro/acv_exporter.py +379 -0
  38. setiastro/saspro/add_stars.py +33 -6
  39. setiastro/saspro/backgroundneutral.py +35 -7
  40. setiastro/saspro/blemish_blaster.py +4 -1
  41. setiastro/saspro/blink_comparator_pro.py +74 -24
  42. setiastro/saspro/clahe.py +4 -1
  43. setiastro/saspro/continuum_subtract.py +4 -1
  44. setiastro/saspro/convo.py +4 -1
  45. setiastro/saspro/cosmicclarity.py +129 -18
  46. setiastro/saspro/crop_dialog_pro.py +123 -7
  47. setiastro/saspro/curve_editor_pro.py +109 -42
  48. setiastro/saspro/doc_manager.py +67 -4
  49. setiastro/saspro/exoplanet_detector.py +120 -28
  50. setiastro/saspro/frequency_separation.py +1158 -204
  51. setiastro/saspro/ghs_dialog_pro.py +81 -16
  52. setiastro/saspro/graxpert.py +1 -0
  53. setiastro/saspro/gui/main_window.py +393 -204
  54. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  55. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  56. setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
  57. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  58. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  59. setiastro/saspro/halobgon.py +4 -0
  60. setiastro/saspro/histogram.py +5 -1
  61. setiastro/saspro/image_combine.py +4 -0
  62. setiastro/saspro/image_peeker_pro.py +4 -0
  63. setiastro/saspro/imageops/stretch.py +531 -62
  64. setiastro/saspro/isophote.py +4 -0
  65. setiastro/saspro/layers.py +13 -9
  66. setiastro/saspro/layers_dock.py +183 -3
  67. setiastro/saspro/legacy/image_manager.py +154 -20
  68. setiastro/saspro/legacy/numba_utils.py +43 -0
  69. setiastro/saspro/legacy/xisf.py +240 -98
  70. setiastro/saspro/live_stacking.py +180 -79
  71. setiastro/saspro/luminancerecombine.py +228 -27
  72. setiastro/saspro/mask_creation.py +174 -15
  73. setiastro/saspro/mfdeconv.py +113 -35
  74. setiastro/saspro/mfdeconvcudnn.py +119 -70
  75. setiastro/saspro/mfdeconvsport.py +112 -35
  76. setiastro/saspro/morphology.py +4 -0
  77. setiastro/saspro/multiscale_decomp.py +51 -12
  78. setiastro/saspro/numba_utils.py +72 -2
  79. setiastro/saspro/ops/commands.py +18 -18
  80. setiastro/saspro/ops/script_editor.py +5 -2
  81. setiastro/saspro/ops/scripts.py +3 -0
  82. setiastro/saspro/perfect_palette_picker.py +37 -3
  83. setiastro/saspro/plate_solver.py +84 -49
  84. setiastro/saspro/psf_viewer.py +119 -37
  85. setiastro/saspro/resources.py +67 -0
  86. setiastro/saspro/rgbalign.py +4 -0
  87. setiastro/saspro/selective_color.py +4 -1
  88. setiastro/saspro/sfcc.py +60 -2
  89. setiastro/saspro/shortcuts.py +142 -23
  90. setiastro/saspro/signature_insert.py +692 -33
  91. setiastro/saspro/stacking_suite.py +1017 -400
  92. setiastro/saspro/star_alignment.py +4 -1
  93. setiastro/saspro/star_spikes.py +4 -0
  94. setiastro/saspro/star_stretch.py +38 -3
  95. setiastro/saspro/stat_stretch.py +702 -128
  96. setiastro/saspro/subwindow.py +786 -360
  97. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  98. setiastro/saspro/wavescale_hdr.py +4 -1
  99. setiastro/saspro/wavescalede.py +4 -1
  100. setiastro/saspro/whitebalance.py +60 -12
  101. setiastro/saspro/widgets/common_utilities.py +28 -21
  102. setiastro/saspro/widgets/resource_monitor.py +109 -59
  103. setiastro/saspro/widgets/spinboxes.py +10 -13
  104. setiastro/saspro/wimi.py +27 -656
  105. setiastro/saspro/wims.py +13 -3
  106. setiastro/saspro/xisf.py +101 -11
  107. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
  108. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
  109. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
@@ -229,6 +229,7 @@ class MenuMixin:
229
229
 
230
230
 
231
231
  m_header = mb.addMenu(self.tr("&Header Mods && Misc"))
232
+ m_header.addAction(self.act_acv_exporter)
232
233
  m_header.addAction(self.act_astrobin_exporter)
233
234
  m_header.addAction(self.act_batch_convert)
234
235
  m_header.addAction(self.act_batch_renamer)
@@ -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" # inactive titlebar
146
- active = "#242424" # active titlebar
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
- # No override in light / system modes
154
- self.mdi.setStyleSheet("")
155
- return
176
+ base = "#eaeaea"
177
+ active = "#ffffff"
178
+ fg = "#141414"
156
179
 
180
+ # style *our* titlebar only
157
181
  self.mdi.setStyleSheet(f"""
158
- QMdiSubWindow::titlebar {{ background: {base}; color: {fg}; }}
159
- QMdiSubWindow::titlebar:active {{ background: {active}; color: {fg}; }}
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, text)
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(255, 255, 255)
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(210, 210, 210))
449
+ p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, QColor(20, 20, 20))
304
450
 
305
451
  return p
306
452
 
@@ -203,16 +203,16 @@ class ToolbarMixin:
203
203
  tb_fn.addAction(self.act_convo)
204
204
  tb_fn.addAction(self.act_extract_luma)
205
205
 
206
- btn_luma = tb_fn.widgetForAction(self.act_extract_luma)
207
- if isinstance(btn_luma, QToolButton):
208
- luma_menu = QMenu(btn_luma)
209
- luma_menu.addActions(self._luma_group.actions())
210
- btn_luma.setMenu(luma_menu)
211
- btn_luma.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
212
- btn_luma.setStyleSheet("""
213
- QToolButton { color: #dcdcdc; }
214
- QToolButton:pressed, QToolButton:checked { color: #DAA520; font-weight: 600; }
215
- """)
206
+ #btn_luma = tb_fn.widgetForAction(self.act_extract_luma)
207
+ #if isinstance(btn_luma, QToolButton):
208
+ # luma_menu = QMenu(btn_luma)
209
+ # luma_menu.addActions(self._luma_group.actions())
210
+ # btn_luma.setMenu(luma_menu)
211
+ # btn_luma.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
212
+ # btn_luma.setStyleSheet("""
213
+ # QToolButton { color: #dcdcdc; }
214
+ # QToolButton:pressed, QToolButton:checked { color: #DAA520; font-weight: 600; }
215
+ # """)
216
216
 
217
217
  tb_fn.addAction(self.act_recombine_luma)
218
218
  tb_fn.addAction(self.act_rgb_extract)
@@ -361,9 +361,25 @@ class ToolbarMixin:
361
361
  except Exception:
362
362
  pass
363
363
 
364
+
365
+ tb_hidden = DraggableToolBar(self.tr("Hidden"), self)
366
+ tb_hidden.setObjectName("Hidden")
367
+ tb_hidden.setSettingsKey("Toolbar/Hidden")
368
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_hidden)
369
+ tb_hidden.setVisible(False) # <- always hidden
370
+
364
371
  # This can move actions between toolbars, so do it after each toolbar has its base order restored.
365
372
  self._restore_toolbar_memberships()
366
373
 
374
+ # Migrate legacy per-toolbar Hidden lists into the new Hidden toolbar
375
+ self._migrate_old_hidden_sets_to_hidden_toolbar()
376
+
377
+ # (Optional) Re-apply per-toolbar order after migration (since we removed actions)
378
+ for _tb in self.findChildren(DraggableToolBar):
379
+ key = getattr(_tb, "_settings_key", None)
380
+ if key:
381
+ self._restore_toolbar_order(_tb, str(key))
382
+
367
383
  # Re-apply hidden state AFTER memberships (actions may have moved toolbars).
368
384
  # This also guarantees correctness even if any toolbar was rebuilt/adjusted internally.
369
385
  for _tb in self.findChildren(DraggableToolBar):
@@ -372,7 +388,9 @@ class ToolbarMixin:
372
388
  except Exception:
373
389
  pass
374
390
 
391
+ # Rebind ALL dropdowns after reorder/membership moves
375
392
  self._rebind_view_dropdowns()
393
+ self._rebind_extract_luma_dropdown()
376
394
 
377
395
  def _toolbar_containing_action(self, action: QAction):
378
396
  from setiastro.saspro.shortcuts import DraggableToolBar
@@ -381,6 +399,140 @@ class ToolbarMixin:
381
399
  return tb
382
400
  return None
383
401
 
402
+ def _rebind_extract_luma_dropdown(self):
403
+ from PyQt6.QtWidgets import QMenu, QToolButton
404
+ from setiastro.saspro.luminancerecombine import LUMA_PROFILES
405
+
406
+ tb = self._toolbar_containing_action(self.act_extract_luma)
407
+ if not tb:
408
+ return
409
+
410
+ btn = tb.widgetForAction(self.act_extract_luma)
411
+ if not isinstance(btn, QToolButton):
412
+ return
413
+
414
+ menu = QMenu(btn)
415
+
416
+ # Ensure cache exists (created in _create_actions(), but be defensive)
417
+ if not hasattr(self, "_luma_sensor_actions"):
418
+ self._luma_sensor_actions = {} # key -> QAction
419
+
420
+ cur_method = str(getattr(self, "luma_method", "rec709"))
421
+
422
+ # ============================================================
423
+ # PATCH SITE #1 (Standard menu) <-- THIS IS WHERE "C" GOES
424
+ # Find your old block:
425
+ #
426
+ # # ---- Standard (use your QActionGroup, keep it simple) ----
427
+ # if getattr(self, "_luma_group", None) is not None:
428
+ # std_menu = QMenu(self.tr("Standard"), menu)
429
+ # std_menu.addActions(self._luma_group.actions())
430
+ # menu.addMenu(std_menu)
431
+ #
432
+ # Replace it with the block below.
433
+ # ============================================================
434
+ if getattr(self, "_luma_group", None) is not None:
435
+ # Sync standard checked states from self.luma_method
436
+ for a in self._luma_group.actions():
437
+ data = str(a.data() or "")
438
+ if data.startswith("sensor:"):
439
+ continue # standards only in Standard menu
440
+ a.blockSignals(True)
441
+ try:
442
+ a.setChecked(data == cur_method)
443
+ finally:
444
+ a.blockSignals(False)
445
+
446
+ # Add ONLY standard actions to the Standard menu (exclude sensors)
447
+ std_menu = QMenu(self.tr("Standard"), menu)
448
+ for a in self._luma_group.actions():
449
+ data = str(a.data() or "")
450
+ if data.startswith("sensor:"):
451
+ continue
452
+ std_menu.addAction(a)
453
+ menu.addMenu(std_menu)
454
+
455
+ # ---- Sensors (nested by category path) ----
456
+ sensors_root = QMenu(self.tr("Sensors"), menu)
457
+
458
+ # Build tree of submenus keyed by "Sensors/<path>"
459
+ submenu_cache: dict[str, QMenu] = {}
460
+
461
+ def get_or_make_path(root_menu: QMenu, path: str) -> QMenu:
462
+ parts = [p for p in path.split("/") if p.strip()]
463
+ cur = root_menu
464
+ acc = ""
465
+ for part in parts:
466
+ acc = f"{acc}/{part}" if acc else part
467
+ if acc not in submenu_cache:
468
+ sm = QMenu(self.tr(part), cur)
469
+ cur.addMenu(sm)
470
+ submenu_cache[acc] = sm
471
+ cur = submenu_cache[acc]
472
+ return cur
473
+
474
+ any_sensor = False
475
+ for key, prof in LUMA_PROFILES.items():
476
+ if not str(key).startswith("sensor:"):
477
+ continue
478
+
479
+ cat = str(prof.get("category", "Sensors/Other"))
480
+ group_path = cat.split("Sensors/", 1)[1] if "Sensors/" in cat else cat
481
+ parent_menu = get_or_make_path(sensors_root, group_path)
482
+
483
+ display_name = key.split("sensor:", 1)[1].strip()
484
+ desc = str(prof.get("description", display_name))
485
+ info = str(prof.get("info", "")).strip()
486
+
487
+ # ============================================================
488
+ # PATCH SITE #2 (Sensors become mutually exclusive with standards)
489
+ # - Cache the QAction so we don't pile up connections/actions
490
+ # - Add it to the SAME QActionGroup (exclusive) as standards
491
+ # - Do NOT use _pick_sensor; rely on QActionGroup.triggered
492
+ # ============================================================
493
+ act = self._luma_sensor_actions.get(key)
494
+ if act is None:
495
+ act = parent_menu.addAction(self.tr(display_name))
496
+ act.setCheckable(True)
497
+ act.setData(key) # IMPORTANT: enables group.triggered to set luma_method
498
+
499
+ # Put sensors into the SAME exclusive group so selecting one
500
+ # deselects everything else (standards and other sensors).
501
+ if getattr(self, "_luma_group", None) is not None:
502
+ self._luma_group.addAction(act)
503
+
504
+ self._luma_sensor_actions[key] = act
505
+ else:
506
+ # Reuse action, but re-add it to the correct submenu location
507
+ parent_menu.addAction(act)
508
+ act.setText(self.tr(display_name))
509
+
510
+ # Update UI info each bind (safe if translations change)
511
+ if info:
512
+ act.setStatusTip(info)
513
+ act.setToolTip(f"{desc}\n{info}")
514
+ else:
515
+ act.setToolTip(desc)
516
+
517
+ # Checked state reflects current selection
518
+ act.blockSignals(True)
519
+ try:
520
+ act.setChecked(cur_method == str(key))
521
+ finally:
522
+ act.blockSignals(False)
523
+
524
+ any_sensor = True
525
+
526
+ if any_sensor:
527
+ menu.addMenu(sensors_root)
528
+
529
+ btn.setMenu(menu)
530
+ btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
531
+ btn.setStyleSheet("""
532
+ QToolButton { color: #dcdcdc; }
533
+ QToolButton:pressed, QToolButton:checked { color: #DAA520; font-weight: 600; }
534
+ """)
535
+
384
536
 
385
537
  def _rebind_view_dropdowns(self):
386
538
  """
@@ -767,6 +919,7 @@ class ToolbarMixin:
767
919
  self.luma_method = getattr(self, "luma_method", "rec709") # default
768
920
  self._luma_group = QActionGroup(self)
769
921
  self._luma_group.setExclusive(True)
922
+ self._luma_sensor_actions = {} # key -> QAction
770
923
 
771
924
  def _mk(method_key, text):
772
925
  act = QAction(text, self, checkable=True)
@@ -786,8 +939,10 @@ class ToolbarMixin:
786
939
 
787
940
  # update method when user picks from the menu
788
941
  def _on_luma_pick(act):
789
- self.luma_method = act.data()
790
- # (optional) persist
942
+ key = act.data()
943
+ if key is None:
944
+ return
945
+ self.luma_method = str(key)
791
946
  try:
792
947
  self.settings.setValue("ui/luminance_method", self.luma_method)
793
948
  except Exception:
@@ -1105,6 +1260,13 @@ class ToolbarMixin:
1105
1260
  self.act_copy_astrometry = QAction(self.tr("Copy Astrometric Solution..."), self)
1106
1261
  self.act_copy_astrometry.triggered.connect(self._open_copy_astrometry)
1107
1262
 
1263
+ self.act_acv_exporter = QAction(self.tr("Astro Catalogue Viewer Exporter..."), self)
1264
+ self.act_acv_exporter.setIconVisibleInMenu(True)
1265
+ self.act_acv_exporter.setStatusTip(self.tr("Export current view into Astro Catalogue Viewer folders"))
1266
+ self.act_acv_exporter.triggered.connect(self._open_acv_exporter)
1267
+
1268
+
1269
+
1108
1270
  # Create Mask
1109
1271
  self.act_create_mask = QAction(QIcon(maskcreate_path), self.tr("Create Mask..."), self)
1110
1272
  self.act_create_mask.setIconVisibleInMenu(True)
@@ -1356,6 +1518,188 @@ class ToolbarMixin:
1356
1518
  if key:
1357
1519
  self._restore_toolbar_order(tb, str(key))
1358
1520
 
1521
+ def _hidden_toolbar(self):
1522
+ from setiastro.saspro.shortcuts import DraggableToolBar
1523
+ for tb in self.findChildren(DraggableToolBar):
1524
+ if getattr(tb, "_settings_key", None) == "Toolbar/Hidden" or tb.objectName() == "Hidden":
1525
+ return tb
1526
+ return None
1527
+
1528
+ def _toolbar_by_settings_key(self, key: str):
1529
+ from setiastro.saspro.shortcuts import DraggableToolBar
1530
+ for tb in self.findChildren(DraggableToolBar):
1531
+ if getattr(tb, "_settings_key", None) == key:
1532
+ return tb
1533
+ return None
1534
+
1535
+ def _action_cid(self, act):
1536
+ return str(act.property("command_id") or act.objectName() or "")
1537
+
1538
+ def _hide_action_to_hidden_toolbar(self, act):
1539
+ """
1540
+ Move action to the Hidden toolbar, remembering where it came from.
1541
+ """
1542
+ tb_hidden = self._hidden_toolbar()
1543
+ if not tb_hidden:
1544
+ return
1545
+
1546
+ cid = self._action_cid(act)
1547
+ if not cid:
1548
+ return
1549
+
1550
+ # Find current toolbar (if any) and remember it
1551
+ cur_tb = self._toolbar_containing_action(act)
1552
+ if cur_tb and getattr(cur_tb, "_settings_key", None) and cur_tb is not tb_hidden:
1553
+ prev_key = str(cur_tb._settings_key)
1554
+
1555
+ # Persist "previous toolbar" so unhide can restore
1556
+ try:
1557
+ raw = self.settings.value("Toolbar/HiddenPrev", "", type=str) or ""
1558
+ except Exception:
1559
+ raw = ""
1560
+ try:
1561
+ prev = json.loads(raw) if raw else {}
1562
+ except Exception:
1563
+ prev = {}
1564
+ prev[cid] = prev_key
1565
+ self.settings.setValue("Toolbar/HiddenPrev", json.dumps(prev))
1566
+
1567
+ # Remove from all toolbars, add to hidden
1568
+ from setiastro.saspro.shortcuts import DraggableToolBar
1569
+ for t in self.findChildren(DraggableToolBar):
1570
+ if act in t.actions():
1571
+ t.removeAction(act)
1572
+
1573
+ tb_hidden.addAction(act)
1574
+
1575
+ # Update assignment mapping so restore works on next launch
1576
+ tb_hidden._update_assignment_for_action(act)
1577
+
1578
+ # Persist ordering (optional but nice)
1579
+ try:
1580
+ tb_hidden._persist_order()
1581
+ except Exception:
1582
+ pass
1583
+ if cur_tb:
1584
+ try:
1585
+ cur_tb._persist_order()
1586
+ except Exception:
1587
+ pass
1588
+
1589
+ def _unhide_action_from_hidden_toolbar(self, act):
1590
+ """
1591
+ Move action out of Hidden toolbar back to its remembered toolbar.
1592
+ Falls back to Toolbar/View if missing.
1593
+ """
1594
+ tb_hidden = self._hidden_toolbar()
1595
+ if not tb_hidden:
1596
+ return
1597
+
1598
+ cid = self._action_cid(act)
1599
+ if not cid:
1600
+ return
1601
+
1602
+ # Load prev mapping
1603
+ try:
1604
+ raw = self.settings.value("Toolbar/HiddenPrev", "", type=str) or ""
1605
+ except Exception:
1606
+ raw = ""
1607
+ try:
1608
+ prev = json.loads(raw) if raw else {}
1609
+ except Exception:
1610
+ prev = {}
1611
+
1612
+ target_key = prev.get(cid) or "Toolbar/View"
1613
+ tb_target = self._toolbar_by_settings_key(target_key) or self._toolbar_by_settings_key("Toolbar/View")
1614
+ if not tb_target:
1615
+ return
1616
+
1617
+ tb_hidden.removeAction(act)
1618
+ tb_target.addAction(act)
1619
+
1620
+ # Update assignment mapping
1621
+ tb_target._update_assignment_for_action(act)
1622
+
1623
+ # Cleanup prev mapping (optional)
1624
+ if cid in prev:
1625
+ prev.pop(cid, None)
1626
+ self.settings.setValue("Toolbar/HiddenPrev", json.dumps(prev))
1627
+
1628
+ try:
1629
+ tb_target._persist_order()
1630
+ tb_hidden._persist_order()
1631
+ except Exception:
1632
+ pass
1633
+
1634
+ def _migrate_old_hidden_sets_to_hidden_toolbar(self):
1635
+ """
1636
+ One-time migration:
1637
+ Old system: per-toolbar QSettings lists at "<ToolbarKey>/Hidden" storing action IDs
1638
+ New system: move those actions into the dedicated Hidden toolbar (Toolbar/Hidden)
1639
+ and persist membership via Toolbar/Assignments.
1640
+ """
1641
+ if not hasattr(self, "settings"):
1642
+ return
1643
+
1644
+ # Run once
1645
+ if self.settings.value("Toolbar/HiddenMigrationDone", False, type=bool):
1646
+ return
1647
+
1648
+ tb_hidden = self._hidden_toolbar()
1649
+ if not tb_hidden:
1650
+ return
1651
+
1652
+ from setiastro.saspro.shortcuts import DraggableToolBar
1653
+
1654
+ # If Hidden toolbar already has actions, assume user is already on new system
1655
+ # but we still can migrate any remaining old keys.
1656
+ # (We won't early-return.)
1657
+
1658
+ for tb in self.findChildren(DraggableToolBar):
1659
+ k = getattr(tb, "_settings_key", None)
1660
+ if not k or str(k) == "Toolbar/Hidden":
1661
+ continue
1662
+
1663
+ # Old storage key
1664
+ old_key = f"{k}/Hidden"
1665
+
1666
+ # Read old hidden list (Qt backend may return list OR str)
1667
+ try:
1668
+ raw = self.settings.value(old_key, [], type=list)
1669
+ except Exception:
1670
+ raw = self.settings.value(old_key, []) # fallback
1671
+
1672
+ if not raw:
1673
+ continue
1674
+
1675
+ if isinstance(raw, str):
1676
+ raw_list = [raw]
1677
+ else:
1678
+ try:
1679
+ raw_list = list(raw)
1680
+ except Exception:
1681
+ raw_list = []
1682
+
1683
+ hidden_ids = {str(x) for x in raw_list if str(x).strip()}
1684
+ if not hidden_ids:
1685
+ # Clear junk so we don’t keep trying
1686
+ self.settings.setValue(old_key, [])
1687
+ continue
1688
+
1689
+ # Move matching actions into Hidden toolbar
1690
+ for act in list(tb.actions()):
1691
+ if act is None or act.isSeparator():
1692
+ continue
1693
+ cid = str(act.property("command_id") or act.objectName() or "")
1694
+ if cid and cid in hidden_ids:
1695
+ self._hide_action_to_hidden_toolbar(act)
1696
+
1697
+ # Clear old storage so you don't keep re-migrating
1698
+ self.settings.setValue(old_key, [])
1699
+
1700
+ # Mark migration complete
1701
+ self.settings.setValue("Toolbar/HiddenMigrationDone", True)
1702
+
1359
1703
 
1360
1704
  def update_undo_redo_action_labels(self):
1361
1705
  if not hasattr(self, "act_undo"): # not built yet