setiastrosuitepro 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,2807 @@
1
+ # pro/shortcuts.py
2
+ from __future__ import annotations
3
+ import json
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Optional
7
+ import uuid
8
+
9
+ from PyQt6.QtCore import (Qt, QPoint, QRect, QMimeData, QSettings, QByteArray,
10
+ QDataStream, QIODevice, QEvent, QSize)
11
+ from PyQt6.QtGui import (QAction, QDrag, QIcon, QMouseEvent, QPixmap, QKeyEvent, QKeyEvent, QCursor, QKeySequence)
12
+ from PyQt6.QtWidgets import (QToolBar, QWidget, QToolButton, QMenu, QApplication, QVBoxLayout, QHBoxLayout, QComboBox, QGroupBox, QGridLayout, QDoubleSpinBox, QSpinBox,
13
+ QInputDialog, QMessageBox, QDialog, QSlider,
14
+ QFormLayout, QDialogButtonBox, QDoubleSpinBox, QCheckBox, QLabel, QRubberBand, QRadioButton, QPlainTextEdit, QTabWidget, QLineEdit, QPushButton, QFileDialog)
15
+
16
+ from PyQt6.QtWidgets import QMdiArea, QMdiSubWindow
17
+ # _LinearFitPresetDialog loaded on demand (see line ~334)
18
+
19
+
20
+
21
+ try:
22
+ from PyQt6 import sip
23
+ except Exception:
24
+ sip = None
25
+
26
+ from setiastro.saspro.dnd_mime import MIME_VIEWSTATE, MIME_CMD, MIME_MASK, MIME_ACTION
27
+
28
+ from pathlib import Path
29
+ import os # ← NEW
30
+
31
+ SASS_KIND = "sas.shortcuts"
32
+ SASS_VER = 1
33
+
34
+ # Accept these endings (case-insensitive)
35
+ OPENABLE_ENDINGS = (
36
+ ".png", ".jpg", ".jpeg",
37
+ ".tif", ".tiff",
38
+ ".fits", ".fit",
39
+ ".fits.gz", ".fit.gz", ".fz",
40
+ ".xisf",
41
+ ".cr2", ".cr3", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2", ".pef",
42
+ )
43
+
44
+ _ICONS = None
45
+
46
+ def _get_icons():
47
+ """Lazy-load icons so shortcuts.py can be imported early without circular deps."""
48
+ global _ICONS
49
+ if _ICONS is not None:
50
+ return _ICONS
51
+
52
+ # Find where get_icons() lives in your project and import it here.
53
+ # Try a couple common locations; keep the first that exists in your tree.
54
+ try:
55
+ from setiastro.saspro.resources import get_icons as _gi
56
+ except Exception:
57
+ _gi = None
58
+
59
+ _ICONS = _gi() if _gi else None
60
+ return _ICONS
61
+
62
+
63
+ def _is_dead(w) -> bool:
64
+ """True if widget is None or its C++ has been destroyed."""
65
+ if w is None:
66
+ return True
67
+ if sip is not None:
68
+ try:
69
+ return sip.isdeleted(w)
70
+ except Exception:
71
+ return False
72
+ # sip not available: best-effort heuristic
73
+ try:
74
+ _ = w.parent() # will raise on dead wrappers
75
+ return False
76
+ except RuntimeError:
77
+ return True
78
+
79
+ # ---------- constants / helpers ----------
80
+
81
+ SET_KEY_V1 = "Shortcuts/v1" # legacy (id-less)
82
+ SET_KEY_V2 = "Shortcuts/v2" # new: stores id, label, etc.
83
+ SET_KEY = SET_KEY_V2
84
+ KEYBINDS_KEY = "Keybinds/v1" # JSON dict: {command_id: "Ctrl+Alt+S"}
85
+
86
+ # Used when dragging a DESKTOP shortcut onto a view for headless run
87
+
88
+
89
+ def _pack_cmd_payload(command_id: str, preset: dict | None = None) -> bytes:
90
+ return json.dumps({"command_id": command_id, "preset": preset or {}}).encode("utf-8")
91
+
92
+ def _unpack_cmd_payload(b: bytes) -> dict:
93
+ return json.loads(b.decode("utf-8"))
94
+
95
+
96
+ @dataclass
97
+ class ShortcutEntry:
98
+ shortcut_id: str
99
+ command_id: str
100
+ x: int
101
+ y: int
102
+ label: str
103
+
104
+ # ---------- a QToolBar that supports Alt+drag to create shortcuts ----------
105
+ class DraggableToolBar(QToolBar):
106
+ """
107
+ Alt/Ctrl/Shift + Left-drag a toolbar button to create a desktop shortcut.
108
+ We hook QToolButton children (not the toolbar itself), because
109
+ mouse events go to the buttons.
110
+ """
111
+ def __init__(self, *a, **k):
112
+ super().__init__(*a, **k)
113
+ self._press_pos: dict[QToolButton, QPoint] = {}
114
+ self._dragging_from: QToolButton | None = None
115
+ self._press_had_mod: dict[QToolButton, bool] = {}
116
+ self._suppress_release: set[QToolButton] = set()
117
+ self._settings_key: str | None = None
118
+
119
+ # NEW: called by main window / mixin
120
+ def setSettingsKey(self, key: str):
121
+ """Set the settings key for persisting toolbar state."""
122
+ self._settings_key = str(key)
123
+
124
+ def _mods_ok(self, mods: Qt.KeyboardModifiers) -> bool:
125
+ return bool(mods & (
126
+ Qt.KeyboardModifier.AltModifier |
127
+ Qt.KeyboardModifier.ControlModifier |
128
+ Qt.KeyboardModifier.ShiftModifier
129
+ ))
130
+
131
+ # install/remove our event filter when actions are added/removed
132
+ def actionEvent(self, e):
133
+ super().actionEvent(e)
134
+ t = e.type()
135
+ if t == QEvent.Type.ActionAdded:
136
+ act = e.action()
137
+ btn = self.widgetForAction(act)
138
+ if isinstance(btn, QToolButton):
139
+ btn.installEventFilter(self)
140
+ elif t == QEvent.Type.ActionRemoved:
141
+ act = e.action()
142
+ btn = self.widgetForAction(act)
143
+ if isinstance(btn, QToolButton):
144
+ try:
145
+ btn.removeEventFilter(self)
146
+ except Exception:
147
+ pass
148
+
149
+ def eventFilter(self, obj, ev):
150
+ if isinstance(obj, QToolButton):
151
+ # RIGHT CLICK → show "Create Desktop Shortcut"
152
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.RightButton:
153
+ act = self._find_action_for_button(obj)
154
+ if act:
155
+ self._show_toolbutton_context_menu(obj, act, ev.globalPosition().toPoint())
156
+ return True # consume
157
+ return False
158
+
159
+ # Keyboard/trackpad context menu event
160
+ if ev.type() == QEvent.Type.ContextMenu:
161
+ act = self._find_action_for_button(obj)
162
+ if act:
163
+ self._show_toolbutton_context_menu(obj, act, ev.globalPos())
164
+ return True
165
+ return False
166
+ # L-press: remember start + whether a drag-modifier was held
167
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
168
+ self._press_pos[obj] = ev.globalPosition().toPoint()
169
+ self._press_had_mod[obj] = self._mods_ok(QApplication.keyboardModifiers())
170
+ return False # allow normal press visuals
171
+
172
+ # Move with L held: if (had-mod at press OR has-mod now) AND moved enough → start drag
173
+ if ev.type() == QEvent.Type.MouseMove and (ev.buttons() & Qt.MouseButton.LeftButton):
174
+ start = self._press_pos.get(obj)
175
+ if start is not None:
176
+ delta = ev.globalPosition().toPoint() - start
177
+ if ((self._press_had_mod.get(obj, False) or self._mods_ok(QApplication.keyboardModifiers()))
178
+ and delta.manhattanLength() > QApplication.startDragDistance()):
179
+ # find the QAction backing this button
180
+ act = next((a for a in self.actions() if self.widgetForAction(a) is obj), None)
181
+ if act:
182
+ self._start_drag_for_action(act)
183
+ # eat subsequent release so the action doesn't trigger
184
+ self._suppress_release.add(obj)
185
+ # clear press tracking
186
+ self._press_pos.pop(obj, None)
187
+ self._press_had_mod.pop(obj, None)
188
+ return True # consume the move (prevents click)
189
+ return False
190
+
191
+ # Release: if we started a drag, swallow the release so click won't fire
192
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
193
+ self._press_pos.pop(obj, None)
194
+ self._press_had_mod.pop(obj, None)
195
+ if obj in self._suppress_release:
196
+ self._suppress_release.discard(obj)
197
+ return True # eat release → no click
198
+ return False
199
+
200
+ return super().eventFilter(obj, ev)
201
+
202
+ def _start_drag_for_action(self, act: QAction):
203
+ act_id = act.property("command_id") or act.objectName()
204
+ if not act_id:
205
+ return
206
+
207
+ mods = QApplication.keyboardModifiers()
208
+ alt = bool(mods & Qt.KeyboardModifier.AltModifier)
209
+
210
+ md = QMimeData()
211
+ if alt:
212
+ # Put BOTH payloads on the drag:
213
+ # 1) MIME_CMD → lets you drop directly on a View or Function Bundle chip
214
+ # (use per-command preset if available, else empty dict)
215
+ s = QSettings()
216
+ raw = s.value(f"presets/{act_id}", "", type=str) or ""
217
+ try:
218
+ preset = json.loads(raw) if raw else {}
219
+ except Exception:
220
+ preset = {}
221
+ md.setData(MIME_CMD, _pack_cmd_payload(act_id, preset))
222
+
223
+ # 2) MIME_ACTION → canvas still interprets this to create a desktop shortcut
224
+ md.setData(MIME_ACTION, act_id.encode("utf-8"))
225
+ else:
226
+ # Ctrl/Shift (legacy): only create a desktop shortcut
227
+ md.setData(MIME_ACTION, act_id.encode("utf-8"))
228
+
229
+ drag = QDrag(self)
230
+ drag.setMimeData(md)
231
+ pm = act.icon().pixmap(32, 32) if not act.icon().isNull() else QPixmap(32, 32)
232
+ if pm.isNull():
233
+ pm = QPixmap(32, 32); pm.fill(Qt.GlobalColor.darkGray)
234
+ drag.setPixmap(pm)
235
+ drag.setHotSpot(pm.rect().center())
236
+ drag.exec(Qt.DropAction.CopyAction)
237
+
238
+ def _find_action_for_button(self, btn: QToolButton) -> QAction | None:
239
+ # Find the QAction that owns this toolbutton
240
+ for a in self.actions():
241
+ if self.widgetForAction(a) is btn:
242
+ return a
243
+ return None
244
+
245
+ def _add_shortcut_for_action(self, act: QAction):
246
+ # Resolve command id
247
+ act_id = act.property("command_id") or act.objectName()
248
+ if not act_id:
249
+ return
250
+ # Find ShortcutManager on the main window
251
+ mw = self.window()
252
+ mgr = getattr(mw, "shortcuts", None)
253
+ mdi = getattr(mw, "mdi", None)
254
+ if mgr is None or mdi is None:
255
+ return
256
+ # Map current cursor pos (global) into the viewport
257
+ gpos = QCursor.pos()
258
+ vp = mdi.viewport()
259
+ pos = vp.mapFromGlobal(gpos)
260
+ # Clamp into viewport rect (center if way out of bounds)
261
+ rect = vp.rect()
262
+ if not rect.contains(pos):
263
+ pos = rect.center()
264
+ mgr.add_shortcut(str(act_id), pos)
265
+
266
+ def _show_toolbutton_context_menu(self, btn: QToolButton, act: QAction, gpos: QPoint):
267
+ m = QMenu(btn)
268
+ m.addAction("Create Desktop Shortcut", lambda: self._add_shortcut_for_action(act))
269
+ # (Optional) teach users about Alt+Drag:
270
+ m.addSeparator()
271
+ m.addAction("Tip: Alt+Drag to create", lambda: None).setEnabled(False)
272
+ m.exec(gpos)
273
+
274
+ _PRESET_UI_IDS = {
275
+ "stat_stretch","star_stretch","crop","curves","ghs","abe","graxpert",
276
+ "remove_stars","cosmic_clarity","cosmic","cosmicclarity",
277
+ "convo","convolution","deconvolution","convo_deconvo",
278
+ "linear_fit","wavescale_hdr","wavescale_dark_enhance","wavescale_dark_enhancer",
279
+ "remove_green","star_align","background_neutral","white_balance","clahe",
280
+ "morphology","pixel_math","rgb_align","signature_insert","signature_adder",
281
+ "signature","halo_b_gon","geom_rescale","rescale","debayer","image_combine",
282
+ "star_spikes","diffraction_spikes", "multiscale_decomp",
283
+ }
284
+
285
+ def _has_preset_editor_for_command(command_id: str) -> bool:
286
+ """Return True if we have a bespoke UI for this command_id."""
287
+ return command_id in _PRESET_UI_IDS
288
+
289
+ # ---- Shared preset editor helper for other modules (e.g. Function Bundles) ----
290
+ def _open_preset_editor_for_command(parent, command_id: str, initial: dict | None):
291
+ """
292
+ Open the same command-specific preset editor UIs used by ShortcutButton.
293
+ Returns a dict on success (OK), or None if cancelled / no editor available.
294
+ """
295
+ cur = initial or {}
296
+
297
+ # Keep each branch self-contained with local imports to avoid heavy module churn.
298
+ if command_id == "stat_stretch":
299
+ dlg = _StatStretchPresetDialog(parent, initial=cur)
300
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
301
+
302
+ if command_id == "star_stretch":
303
+ dlg = _StarStretchPresetDialog(parent, initial=cur)
304
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
305
+
306
+ if command_id == "crop":
307
+ from setiastro.saspro.shortcuts import _CropPresetDialog
308
+ dlg = _CropPresetDialog(parent, initial=cur or {
309
+ "mode": "margins",
310
+ "margins": {"top": 0, "right": 0, "bottom": 0, "left": 0},
311
+ "create_new_view": False
312
+ })
313
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
314
+
315
+ if command_id == "curves":
316
+ dlg = _CurvesPresetDialog(parent, initial=cur or {"shape":"linear","amount":0.5,"mode":"K (Brightness)"})
317
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
318
+
319
+ if command_id == "ghs":
320
+ dlg = _GHSPresetDialog(parent, initial=cur or {
321
+ "alpha":1.0,"beta":1.0,"gamma":1.0,"pivot":0.5,"lp":0.0,"hp":0.0,"channel":"K (Brightness)"
322
+ })
323
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
324
+
325
+ if command_id == "abe":
326
+ dlg = _ABEPresetDialog(parent, initial=cur or {
327
+ "degree":2, "samples":120, "downsample":6, "patch":15, "rbf":True, "rbf_smooth":1.0, "make_background_doc":False
328
+ })
329
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
330
+
331
+ if command_id == "graxpert":
332
+ from setiastro.saspro.graxpert_preset import GraXpertPresetDialog
333
+ dlg = GraXpertPresetDialog(parent, initial=cur or {"smoothing":0.10,"gpu":True})
334
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
335
+
336
+ if command_id == "remove_stars":
337
+ from setiastro.saspro.remove_stars_preset import RemoveStarsPresetDialog
338
+ dlg = RemoveStarsPresetDialog(parent, initial=cur or {"tool":"starnet","linear":True})
339
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
340
+
341
+ if command_id in ("cosmic_clarity","cosmic","cosmicclarity"):
342
+ from setiastro.saspro.cosmicclarity_preset import _CosmicClarityPresetDialog
343
+ dlg = _CosmicClarityPresetDialog(parent, initial=cur or {
344
+ "mode":"sharpen","gpu":True,"create_new_view":False,"sharpening_mode":"Both",
345
+ "auto_psf":True,"nonstellar_psf":3.0,"stellar_amount":0.50,"nonstellar_amount":0.50,
346
+ "denoise_luma":0.50,"denoise_color":0.50,"denoise_mode":"full","separate_channels":False,"scale":2
347
+ })
348
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
349
+
350
+ if command_id in ("convo","convolution","deconvolution","convo_deconvo"):
351
+ from setiastro.saspro.convo_preset import ConvoPresetDialog
352
+ dlg = ConvoPresetDialog(parent, initial=cur or {
353
+ "op":"convolution","radius":5.0,"kurtosis":2.0,"aspect":1.0,"rotation":0.0,"strength":1.0
354
+ })
355
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
356
+
357
+ if command_id == "linear_fit":
358
+ from setiastro.saspro.linear_fit import _LinearFitPresetDialog
359
+ dlg = _LinearFitPresetDialog(parent, initial=cur)
360
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
361
+
362
+ if command_id == "wavescale_hdr":
363
+ from setiastro.saspro.wavescale_hdr_preset import WaveScaleHDRPresetDialog
364
+ dlg = WaveScaleHDRPresetDialog(parent, initial=cur or {"n_scales":5,"compression_factor":1.5,"mask_gamma":5.0})
365
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
366
+
367
+ if command_id in ("wavescale_dark_enhance","wavescale_dark_enhancer"):
368
+ from setiastro.saspro.wavescalede_preset import WaveScaleDSEPresetDialog
369
+ dlg = WaveScaleDSEPresetDialog(parent, initial=cur or {
370
+ "n_scales":6,"boost_factor":5.0,"mask_gamma":1.0,"iterations":2
371
+ })
372
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
373
+
374
+ if command_id == "multiscale_decomp":
375
+ from setiastro.saspro.multiscale_decomp import _MultiScaleDecompPresetDialog
376
+ dlg = _MultiScaleDecompPresetDialog(parent, initial=cur or {
377
+ "layers": 4,
378
+ "base_sigma": 1.0,
379
+ "linked_rgb": True,
380
+ "layers_cfg": [],
381
+ })
382
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
383
+
384
+ if command_id == "remove_green":
385
+ dlg = _RemoveGreenPresetDialog(parent, initial=cur)
386
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
387
+
388
+ if command_id == "star_align":
389
+ from setiastro.saspro.star_alignment_preset import StarAlignmentPresetDialog
390
+ dlg = StarAlignmentPresetDialog(parent, initial=cur or {"ref_mode":"active","overwrite":False,"downsample":2})
391
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
392
+
393
+ if command_id == "background_neutral":
394
+ dlg = _BackgroundNeutralPresetDialog(parent, initial=cur)
395
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
396
+
397
+ if command_id == "white_balance":
398
+ dlg = _WhiteBalancePresetDialog(parent, initial=cur)
399
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
400
+
401
+ if command_id == "clahe":
402
+ dlg = _CLAHEPresetDialog(parent, initial=cur)
403
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
404
+
405
+ if command_id == "morphology":
406
+ dlg = _MorphologyPresetDialog(parent, initial=cur)
407
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
408
+
409
+ if command_id == "pixel_math":
410
+ dlg = _PixelMathPresetDialog(parent, initial=cur)
411
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
412
+
413
+ if command_id == "rgb_align":
414
+ dlg = _RGBAlignPresetDialog(parent, initial=cur or {"model":"homography","new_doc":True})
415
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
416
+
417
+ if command_id in ("signature_insert","signature_adder","signature"):
418
+ dlg = _SignatureInsertPresetDialog(parent, initial=cur)
419
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
420
+
421
+ if command_id == "halo_b_gon":
422
+ dlg = _HaloBGonPresetDialog(parent, initial=cur)
423
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
424
+
425
+ if command_id in ("geom_rescale","rescale"):
426
+ dlg = _RescalePresetDialog(parent, initial=cur or {"factor":1.0})
427
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
428
+
429
+ if command_id == "debayer":
430
+ dlg = _DebayerPresetDialog(parent, initial=cur or {"pattern":"auto"})
431
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
432
+
433
+ if command_id == "image_combine":
434
+ dlg = _ImageCombinePresetDialog(parent, initial=cur or {
435
+ "mode":"Blend","opacity":1.0,"luma_only":False,"output":"replace","docB_title":""
436
+ })
437
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
438
+
439
+ if command_id in ("star_spikes","diffraction_spikes"):
440
+ dlg = _StarSpikesPresetDialog(parent, initial=cur)
441
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
442
+
443
+ # Unknown / no bespoke UI
444
+ return None
445
+
446
+
447
+ # ---------- the button that sits on the MDI desktop ----------
448
+ class ShortcutButton(QToolButton):
449
+ def __init__(self,
450
+ manager: "ShortcutManager",
451
+ sid: str, # NEW
452
+ command_id: str,
453
+ icon: QIcon,
454
+ label: str, # NEW (display text)
455
+ parent: QWidget):
456
+ super().__init__(parent)
457
+ self._mgr = manager
458
+ self.sid = sid # NEW
459
+ self.command_id = command_id
460
+ self.setIcon(icon)
461
+ self.setText(label) # use label instead of action text
462
+ self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
463
+ self.setIconSize(QPixmap(32, 32).size())
464
+ self.setAutoRaise(True)
465
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
466
+ self.customContextMenuRequested.connect(self._context_menu)
467
+ self._dragging = False
468
+ self._press_pos = None
469
+ self._start_geom = None
470
+ self._did_command_drag = False
471
+ self.setToolTip(
472
+ f"{label}\n• Double-click: open\n• Drag: move\n• Alt/Ctrl+Drag onto a view: headless apply"
473
+ )
474
+
475
+ # --- Preset helpers (QSettings) -------------------------------------
476
+ def _preset_key(self) -> str:
477
+ # per-instance key
478
+ return f"presets/shortcuts/{self.sid}"
479
+
480
+ def _load_preset(self) -> Optional[dict]:
481
+ s = getattr(self._mgr, "settings", QSettings())
482
+ raw = s.value(self._preset_key(), "", type=str) or ""
483
+ if raw:
484
+ try:
485
+ return json.loads(raw)
486
+ except Exception:
487
+ pass
488
+ # fallback: legacy per-command preset if instance hasn’t been saved yet
489
+ legacy = s.value(f"presets/{self.command_id}", "", type=str) or ""
490
+ if legacy:
491
+ try:
492
+ return json.loads(legacy)
493
+ except Exception:
494
+ pass
495
+ return None
496
+
497
+ def _save_preset(self, preset: Optional[dict]):
498
+ s = getattr(self._mgr, "settings", QSettings())
499
+ if preset is None:
500
+ s.remove(self._preset_key())
501
+ else:
502
+ s.setValue(self._preset_key(), json.dumps(preset))
503
+ s.sync()
504
+
505
+ # --- Context menu (run / preset / delete) ----------------------------
506
+ def _context_menu(self, pos):
507
+ m = QMenu(self)
508
+ m.addAction("Run", lambda: self._mgr.trigger(self.command_id))
509
+ m.addSeparator()
510
+ m.addAction("Edit Preset…", self._edit_preset_ui)
511
+ m.addAction("Clear Preset", lambda: self._save_preset(None))
512
+ m.addAction("Rename…", self._rename) # ← NEW
513
+ m.addSeparator()
514
+ m.addAction("Delete", self._delete)
515
+ m.exec(self.mapToGlobal(pos))
516
+
517
+ def _rename(self):
518
+ current = self.text()
519
+ new_name, ok = QInputDialog.getText(self, "Rename Shortcut", "Name:", text=current)
520
+ if not ok or not new_name.strip():
521
+ return
522
+ self.setText(new_name.strip())
523
+ self._mgr.update_label(self.sid, new_name.strip()) # ← was self.shortcut_id
524
+
525
+ def _edit_preset_ui(self):
526
+ cid = self.command_id
527
+ cur = self._load_preset() or {}
528
+ result = _open_preset_editor_for_command(self, cid, cur)
529
+ if result is not None:
530
+ self._save_preset(result)
531
+ QMessageBox.information(self, "Preset saved", "Preset stored on shortcut.")
532
+ return
533
+
534
+ # Fallback: JSON editor
535
+ raw = json.dumps(cur or {}, indent=2)
536
+ text, ok = QInputDialog.getMultiLineText(self, "Edit Preset (JSON)", "Preset:", raw)
537
+ if ok:
538
+ try:
539
+ preset = json.loads(text or "{}")
540
+ if not isinstance(preset, dict):
541
+ raise ValueError("Preset must be a JSON object")
542
+ self._save_preset(preset)
543
+ QMessageBox.information(self, "Preset saved", "Preset stored on shortcut.")
544
+ except Exception as e:
545
+ QMessageBox.warning(self, "Invalid JSON", str(e))
546
+
547
+
548
+ def _start_command_drag(self):
549
+ md = QMimeData()
550
+
551
+ md.setData(MIME_CMD, _pack_cmd_payload(self.command_id, self._load_preset() or {}))
552
+ drag = QDrag(self)
553
+ drag.setMimeData(md)
554
+ pm = self.icon().pixmap(32, 32)
555
+ if pm.isNull():
556
+ pm = QPixmap(32, 32); pm.fill(Qt.GlobalColor.darkGray)
557
+ drag.setPixmap(pm)
558
+ drag.setHotSpot(pm.rect().center())
559
+ drag.exec(Qt.DropAction.CopyAction)
560
+ self._did_command_drag = True
561
+
562
+ # --- Mouse handlers --------------------------------------------------
563
+ def _mods_mean_command_drag(self) -> bool:
564
+ # Use ALT only for headless drag so Ctrl/Shift can be used for multiselect
565
+ return bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier)
566
+
567
+ def mousePressEvent(self, e: QMouseEvent):
568
+ if e.button() == Qt.MouseButton.LeftButton:
569
+ mods = QApplication.keyboardModifiers()
570
+
571
+ if mods & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier):
572
+ self._mgr.toggle_select(self.sid) # ← was self.shortcut_id
573
+ return
574
+
575
+ if self.sid not in self._mgr.selected: # ← was self.shortcut_id
576
+ self._mgr.select_only(self.sid)
577
+
578
+ self._dragging = True
579
+ self._press_pos = e.globalPosition().toPoint()
580
+ self._last_drag_pos = self._press_pos
581
+ self._did_command_drag = False
582
+
583
+ super().mousePressEvent(e)
584
+
585
+ def mouseMoveEvent(self, e: QMouseEvent):
586
+ if self._dragging and self._press_pos is not None:
587
+ cur = e.globalPosition().toPoint()
588
+ step = cur - self._last_drag_pos
589
+ if step.manhattanLength() < QApplication.startDragDistance():
590
+ return super().mouseMoveEvent(e)
591
+
592
+ # If exactly 1 selected and ALT held → command drag (headless)
593
+ if len(self._mgr.selected) == 1 and self._mods_mean_command_drag():
594
+ self._start_command_drag()
595
+ return
596
+
597
+ # Otherwise: move the whole selection by step delta
598
+ self._mgr.move_selected_by(step.x(), step.y())
599
+ self._last_drag_pos = cur
600
+ return
601
+
602
+ super().mouseMoveEvent(e)
603
+
604
+ def mouseReleaseEvent(self, e: QMouseEvent):
605
+ if self._dragging and e.button() == Qt.MouseButton.LeftButton:
606
+ self._dragging = False
607
+ if not self._did_command_drag:
608
+ self._mgr.save_shortcuts() # persist positions after move
609
+ super().mouseReleaseEvent(e)
610
+
611
+ def mouseDoubleClickEvent(self, e: QMouseEvent):
612
+ # double-click still runs the action (open dialog)
613
+ self._mgr.trigger(self.command_id)
614
+
615
+ def _delete(self):
616
+ self._mgr.delete_by_id(self.sid, persist=True) # ← was command_id
617
+
618
+
619
+ def _open_view_bundles_from_canvas(w):
620
+ try:
621
+ from setiastro.saspro.view_bundle import show_view_bundles
622
+ mw = _find_main_window(w)
623
+ show_view_bundles(mw)
624
+ except Exception:
625
+ pass
626
+
627
+ def _open_function_bundles_from_canvas(w):
628
+ try:
629
+ from setiastro.saspro.function_bundle import show_function_bundles
630
+ mw = _find_main_window(w)
631
+ show_function_bundles(mw)
632
+ except Exception:
633
+ pass
634
+
635
+ def _find_main_window(w):
636
+ p = w.parent()
637
+ while p is not None and not (hasattr(p, "doc_manager") or hasattr(p, "docman")):
638
+ p = p.parent()
639
+ return p
640
+
641
+ # ---------- overlay canvas that sits on top of QMdiArea.viewport() ----------
642
+ class ShortcutCanvas(QWidget):
643
+ def __init__(self, mgr: "ShortcutManager", parent: QWidget):
644
+ super().__init__(parent)
645
+ self._mgr = mgr
646
+ self.setAcceptDrops(True)
647
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
648
+ self.setStyleSheet("background: transparent;")
649
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
650
+ self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
651
+ self.setGeometry(parent.rect())
652
+ parent.installEventFilter(self) # keep in sync with viewport size
653
+
654
+ # NEW: rubber-band selection
655
+ self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, self)
656
+ self._rubber_origin = None
657
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # to receive Delete/Ctrl+A
658
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
659
+
660
+ def eventFilter(self, obj, ev):
661
+ # keep sized with viewport
662
+ if obj is self.parent() and ev.type() == ev.Type.Resize:
663
+ self.setGeometry(self.parent().rect())
664
+ return super().eventFilter(obj, ev)
665
+
666
+ # --- rubber-band selection on empty space ---
667
+ def mousePressEvent(self, e: QMouseEvent):
668
+ if e.button() == Qt.MouseButton.LeftButton:
669
+ local = e.position().toPoint()
670
+ # If click hits no child (shortcut), start rubber-band
671
+ if self.childAt(local) is None:
672
+ self._rubber_origin = local
673
+ self._rubber.setGeometry(QRect(self._rubber_origin, self._rubber_origin))
674
+ self._rubber.show()
675
+ # if no add/toggle mods, clear selection first
676
+ if not (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)):
677
+ self._mgr.clear_selection()
678
+ self.setFocus()
679
+ e.accept()
680
+ return
681
+ super().mousePressEvent(e)
682
+
683
+ def mouseMoveEvent(self, e: QMouseEvent):
684
+ if self._rubber.isVisible() and self._rubber_origin is not None:
685
+ rect = QRect(self._rubber_origin, e.position().toPoint()).normalized()
686
+ self._rubber.setGeometry(rect)
687
+ e.accept()
688
+ return
689
+ super().mouseMoveEvent(e)
690
+
691
+ def mouseReleaseEvent(self, e: QMouseEvent):
692
+ if self._rubber.isVisible() and self._rubber_origin is not None:
693
+ rect = QRect(self._rubber_origin, e.position().toPoint()).normalized()
694
+ mode = "add" if (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)) else "replace"
695
+ self._rubber.hide()
696
+ self._rubber_origin = None
697
+ self._mgr.select_in_rect(rect, mode=mode)
698
+ e.accept()
699
+ return
700
+ super().mouseReleaseEvent(e)
701
+
702
+ # --- keyboard: Delete / Backspace / Ctrl+A ---
703
+ def keyPressEvent(self, e: QKeyEvent):
704
+ if e.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
705
+ self._mgr.delete_selected()
706
+ e.accept(); return
707
+ if e.key() == Qt.Key.Key_A and (e.modifiers() & Qt.KeyboardModifier.ControlModifier):
708
+ self._mgr.select_in_rect(self.rect(), mode="replace")
709
+ e.accept(); return
710
+ super().keyPressEvent(e)
711
+
712
+ def dragEnterEvent(self, e):
713
+ md = e.mimeData()
714
+ if md.hasFormat(MIME_ACTION) or md.hasFormat(MIME_CMD) or self._md_has_openable_urls(md):
715
+ self.raise_()
716
+ e.acceptProposedAction()
717
+ else:
718
+ e.ignore()
719
+
720
+ def _top_subwindow_at(self, vp_pos: QPoint) -> QMdiSubWindow | None:
721
+ # Use the correct enum for PyQt6, fall back gracefully if unavailable
722
+ try:
723
+ order_enum = QMdiArea.WindowOrder # PyQt6
724
+ swlist = self._mgr.mdi.subWindowList(order_enum.StackingOrder)
725
+ except Exception:
726
+ # Fallback for older bindings
727
+ swlist = self._mgr.mdi.subWindowList()
728
+
729
+ # Iterate from front-most to back-most (StackingOrder is typically back->front)
730
+ for sw in reversed(swlist):
731
+ if not sw.isVisible():
732
+ continue
733
+ # QMdiSubWindow geometry is in the viewport's coordinate space
734
+ if sw.geometry().contains(vp_pos):
735
+ return sw
736
+ return None
737
+
738
+ def _forward_command_drop(self, e) -> bool:
739
+ from PyQt6.QtWidgets import QApplication
740
+ md = e.mimeData()
741
+ if not md.hasFormat(MIME_CMD):
742
+ return False
743
+ sw = self._top_subwindow_at(e.position().toPoint())
744
+ if sw is None:
745
+ print("[ShortcutCanvas] _forward_command_drop: no subwindow under cursor", flush=True)
746
+ QApplication.processEvents()
747
+ return False
748
+ try:
749
+ raw = bytes(md.data(MIME_CMD))
750
+ payload = _unpack_cmd_payload(raw) # your existing helper
751
+ print(f"[ShortcutCanvas] _forward_command_drop → subwin={sw}, payload={payload!r}", flush=True)
752
+ QApplication.processEvents()
753
+ except Exception as ex:
754
+ print(f"[ShortcutCanvas] _forward_command_drop: failed to unpack payload: {ex!r}", flush=True)
755
+ QApplication.processEvents()
756
+ return False
757
+ self._mgr.apply_command_to_subwindow(sw, payload)
758
+ e.acceptProposedAction()
759
+ return True
760
+
761
+
762
+ def dragMoveEvent(self, e):
763
+ if e.mimeData().hasFormat(MIME_ACTION) or e.mimeData().hasFormat(MIME_CMD) or self._md_has_openable_urls(e.mimeData()):
764
+ e.acceptProposedAction()
765
+ else:
766
+ e.ignore()
767
+
768
+ def dragLeaveEvent(self, e):
769
+ self.lower() # restore
770
+ super().dragLeaveEvent(e)
771
+
772
+ def dropEvent(self, e):
773
+ md = e.mimeData()
774
+
775
+ # 1) route function/preset drops to the front-most subwindow under cursor
776
+ if self._forward_command_drop(e):
777
+ self.lower()
778
+ return
779
+
780
+ # 2) command-only drops (no MIME_ACTION) → create a shortcut with preset
781
+ # This is used by History Explorer Alt+drag.
782
+ if md.hasFormat(MIME_CMD) and not md.hasFormat(MIME_ACTION):
783
+ try:
784
+ raw = bytes(md.data(MIME_CMD))
785
+ payload = _unpack_cmd_payload(raw)
786
+ except Exception:
787
+ payload = None
788
+
789
+ if isinstance(payload, dict) and payload.get("command_id"):
790
+ self._mgr.add_shortcut_from_payload(payload, e.position().toPoint())
791
+ e.acceptProposedAction()
792
+ self.lower()
793
+ return
794
+
795
+ # 3) desktop shortcut creation (MIME_ACTION) → create a button (no preset)
796
+ if md.hasFormat(MIME_ACTION):
797
+ act_id = bytes(md.data(MIME_ACTION)).decode("utf-8")
798
+ self._mgr.add_shortcut(act_id, e.position().toPoint())
799
+ e.acceptProposedAction()
800
+ self.lower()
801
+ return
802
+
803
+ # 4) File / folder open (unchanged)
804
+ if self._md_has_openable_urls(md):
805
+ paths = self._collect_openable_files_from_urls(md)
806
+ if paths:
807
+ opener = getattr(self._mgr.mw, "_handle_external_file_drop", None)
808
+ if callable(opener):
809
+ opener(paths)
810
+ else:
811
+ dm = getattr(self._mgr.mw, "docman", None)
812
+ if dm and hasattr(dm, "open_files") and callable(dm.open_files):
813
+ docs = dm.open_files(paths)
814
+ try:
815
+ for d in (docs or []):
816
+ self._mgr.mw._spawn_subwindow_for(d)
817
+ except Exception:
818
+ pass
819
+ elif dm and hasattr(dm, "open_path") and callable(dm.open_path):
820
+ for p in paths:
821
+ doc = dm.open_path(p)
822
+ if doc is not None:
823
+ self._mgr.mw._spawn_subwindow_for(doc)
824
+ e.acceptProposedAction()
825
+ self.lower()
826
+ return
827
+ self.lower()
828
+ e.ignore()
829
+
830
+ def contextMenuEvent(self, e):
831
+ menu = QMenu(self)
832
+ has_sel = bool(self._mgr.selected)
833
+ a_del = menu.addAction("Delete Selected", self._mgr.delete_selected); a_del.setEnabled(has_sel)
834
+ a_clr = menu.addAction("Clear Selection", self._mgr.clear_selection); a_clr.setEnabled(has_sel)
835
+ menu.addSeparator()
836
+ a_vb = menu.addAction("View Bundles…", lambda: _open_view_bundles_from_canvas(self))
837
+ a_fb = menu.addAction("Function Bundles…", lambda: _open_function_bundles_from_canvas(self))
838
+ menu.exec(e.globalPos())
839
+
840
+
841
+ def mouseDoubleClickEvent(self, e):
842
+ # If user double-clicks empty canvas area, forward to MDI's handler
843
+ if e.button() == Qt.MouseButton.LeftButton:
844
+ local = e.position().toPoint()
845
+ if self.childAt(local) is None:
846
+ try:
847
+ # Reuse your existing connection: mdi.backgroundDoubleClicked -> open_files
848
+ self._mgr.mdi.backgroundDoubleClicked.emit()
849
+ except Exception:
850
+ pass
851
+ e.accept()
852
+ return
853
+ super().mouseDoubleClickEvent(e)
854
+
855
+ def _is_openable_path(self, path: str) -> bool:
856
+ return path.lower().endswith(OPENABLE_ENDINGS)
857
+
858
+ def _md_has_openable_urls(self, md) -> bool:
859
+ if not md.hasUrls():
860
+ return False
861
+ for u in md.urls():
862
+ if not u.isLocalFile():
863
+ continue
864
+ p = u.toLocalFile()
865
+ if os.path.isdir(p):
866
+ return True # we'll scan it on drop
867
+ if self._is_openable_path(p):
868
+ return True
869
+ return False
870
+
871
+ def _collect_openable_files_from_urls(self, md) -> list[str]:
872
+ files: list[str] = []
873
+ if not md.hasUrls():
874
+ return files
875
+ for u in md.urls():
876
+ if not u.isLocalFile():
877
+ continue
878
+ p = u.toLocalFile()
879
+ if os.path.isdir(p):
880
+ # recurse folder for matching files
881
+ for root, _, names in os.walk(p):
882
+ for name in names:
883
+ fp = os.path.join(root, name)
884
+ if self._is_openable_path(fp):
885
+ files.append(fp)
886
+ else:
887
+ if self._is_openable_path(p):
888
+ files.append(p)
889
+ return files
890
+
891
+
892
+ class ShortcutManager:
893
+ def __init__(self, mdi_area, main_window):
894
+ # mdi_area should be your QMdiArea; we attach to its viewport
895
+ self.mdi = mdi_area
896
+ self.mw = main_window
897
+ self.registry: Dict[str, QAction] = {}
898
+ self.canvas = ShortcutCanvas(self, self.mdi.viewport())
899
+ self.canvas.lower() # keep below subwindows (raise() if you want pinned-on-top)
900
+ self.canvas.show()
901
+ self.widgets: Dict[str, ShortcutButton] = {}
902
+ self.settings = QSettings() # shared settings store for positions + presets
903
+ self.selected: set[str] = set() # ← set of shortcut_ids
904
+
905
+ # ---- registry ----
906
+ def register_action(self, command_id: str, action: QAction):
907
+ action.setProperty("command_id", command_id)
908
+ if not action.objectName():
909
+ action.setObjectName(command_id)
910
+
911
+ # Ensure action shortcuts work even if focus is in child widgets / MDI
912
+ action.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
913
+
914
+ self.registry[command_id] = action
915
+
916
+ # Apply saved keybind if present
917
+ kb = self._load_keybinds().get(command_id)
918
+ if kb:
919
+ action.setShortcut(QKeySequence(kb))
920
+
921
+ def find_keybind_conflicts(self) -> dict[str, list[str]]:
922
+ # returns {"Ctrl+Alt+K": ["script:...", "stat_stretch"], ...}
923
+ conflicts = {}
924
+ for cid, act in self.registry.items():
925
+ ks = act.shortcut().toString()
926
+ if not ks:
927
+ continue
928
+ conflicts.setdefault(ks, []).append(cid)
929
+ return {k:v for k,v in conflicts.items() if len(v) > 1}
930
+
931
+ def trigger(self, command_id: str):
932
+ act = self.registry.get(command_id)
933
+ if act:
934
+ act.trigger()
935
+
936
+ def _on_widget_destroyed(self, sid: str):
937
+ # Called from QObject.destroyed — never touch the widget, just clean maps
938
+ self.widgets.pop(sid, None)
939
+ self.selected.discard(sid)
940
+
941
+ def _collect_live_items(self) -> list[dict]:
942
+ """
943
+ Collect visible shortcut widgets into a serializable list.
944
+
945
+ For each shortcut we also try to inline its preset (if any) so that
946
+ function bundles and other per-instance presets can be exported.
947
+ """
948
+ data = []
949
+ for sid, w in list(self.widgets.items()):
950
+ if _is_dead(w):
951
+ self.widgets.pop(sid, None)
952
+ self.selected.discard(sid)
953
+ continue
954
+ try:
955
+ if not w.isVisible():
956
+ continue
957
+
958
+ p = w.pos()
959
+ item = {
960
+ "id": sid,
961
+ "command_id": getattr(w, "command_id", None),
962
+ "label": w.text(),
963
+ "x": int(p.x()),
964
+ "y": int(p.y()),
965
+ }
966
+
967
+ # Try to inline per-instance preset
968
+ preset = None
969
+ try:
970
+ if hasattr(w, "_load_preset"):
971
+ preset = w._load_preset()
972
+ except Exception:
973
+ preset = None
974
+
975
+ if isinstance(preset, dict) and preset:
976
+ item["preset"] = preset
977
+
978
+ data.append(item)
979
+ except RuntimeError:
980
+ self.widgets.pop(sid, None)
981
+ self.selected.discard(sid)
982
+
983
+ # Debug: summarize what we collected
984
+ try:
985
+ summary = []
986
+ for it in data:
987
+ cid = it.get("command_id")
988
+ has_preset = "preset" in it
989
+ summary.append(f"{cid!r} preset={has_preset}")
990
+ self._debug(f"_collect_live_items: {len(data)} item(s): " + ", ".join(summary))
991
+ except Exception:
992
+ pass
993
+
994
+ return data
995
+
996
+ def _export_function_bundles_for_shortcuts(self) -> dict | None:
997
+ """
998
+ Ask pro.function_bundle for function bundle defs + chip layout
999
+ so we can embed them into the .sass export.
1000
+ """
1001
+ try:
1002
+ from setiastro.saspro.function_bundle import export_function_bundles_payload
1003
+ except Exception:
1004
+ return None
1005
+ try:
1006
+ fb = export_function_bundles_payload()
1007
+ if isinstance(fb, dict):
1008
+ return fb
1009
+ except Exception:
1010
+ pass
1011
+ return None
1012
+
1013
+ def _import_function_bundles_for_shortcuts(self, payload: dict | None, replace_existing: bool):
1014
+ """
1015
+ Restore function bundle defs + chips after a .sass import.
1016
+ """
1017
+ if not isinstance(payload, dict):
1018
+ return
1019
+ try:
1020
+ from setiastro.saspro.function_bundle import import_function_bundles_payload
1021
+ except Exception:
1022
+ return
1023
+ try:
1024
+ mw = getattr(self, "mw", None)
1025
+ import_function_bundles_payload(payload, mw, replace_existing=replace_existing)
1026
+ except Exception:
1027
+ pass
1028
+
1029
+
1030
+ def _debug(self, msg: str):
1031
+ """Best-effort debug logging for shortcuts."""
1032
+ try:
1033
+ # Prefer main window log if available
1034
+ if hasattr(self.mw, "_log") and callable(self.mw._log):
1035
+ self.mw._log(f"[Shortcuts] {msg}")
1036
+ return
1037
+ except Exception:
1038
+ pass
1039
+ # Fallback to stdout
1040
+ try:
1041
+ print(f"[Shortcuts] {msg}")
1042
+ except Exception:
1043
+ pass
1044
+
1045
+
1046
+ # ---------- New: export/import ----------
1047
+ def export_to_file(self, file_path: str) -> tuple[bool, str]:
1048
+ try:
1049
+ fp = self._ensure_ext(file_path, ".sass")
1050
+
1051
+ items = self._collect_live_items()
1052
+ payload = {
1053
+ "kind": SASS_KIND,
1054
+ "version": SASS_VER,
1055
+ "exported_at": int(time.time()),
1056
+ "items": items,
1057
+ }
1058
+
1059
+ # NEW: include function bundles + chip layout if available
1060
+ fb_payload = self._export_function_bundles_for_shortcuts()
1061
+ if fb_payload is not None:
1062
+ payload["function_bundles"] = fb_payload
1063
+
1064
+ Path(fp).write_text(json.dumps(payload, indent=2), encoding="utf-8")
1065
+
1066
+ # optional debug
1067
+ try:
1068
+ self._debug(f"export_to_file → {fp}, shortcuts={len(items)}, "
1069
+ f"fb_bundles={len((fb_payload or {}).get('bundles', []))}")
1070
+ except Exception:
1071
+ pass
1072
+
1073
+ return True, fp
1074
+ except Exception as e:
1075
+ try:
1076
+ self._debug(f"export_to_file FAILED: {e}")
1077
+ except Exception:
1078
+ pass
1079
+ return False, str(e)
1080
+
1081
+ def import_from_file(self, file_path: str, *, replace_existing: bool = False) -> tuple[bool, str]:
1082
+ try:
1083
+ txt = Path(file_path).read_text(encoding="utf-8")
1084
+ obj = json.loads(txt)
1085
+
1086
+ fb_payload = None
1087
+
1088
+ # Basic validation (accepts legacy raw arrays too)
1089
+ if isinstance(obj, dict) and obj.get("kind") == SASS_KIND:
1090
+ items = obj.get("items", [])
1091
+ fb_payload = obj.get("function_bundles")
1092
+ elif isinstance(obj, list):
1093
+ # legacy: straight array of items (no function bundle info)
1094
+ items = obj
1095
+ else:
1096
+ return False, "File is not a SAS shortcuts file."
1097
+
1098
+ # optional debug
1099
+ try:
1100
+ self._debug(
1101
+ f"import_from_file ← {file_path}, items={len(items)}, "
1102
+ f"has_fb={isinstance(fb_payload, dict)}, replace_existing={replace_existing}"
1103
+ )
1104
+ except Exception:
1105
+ pass
1106
+
1107
+ if replace_existing:
1108
+ self.clear() # clears both UI + settings keys, keeps manager ready
1109
+
1110
+ # Build widgets (shortcuts) as before
1111
+ for it in items:
1112
+ cid = it.get("command_id")
1113
+ if not cid:
1114
+ continue
1115
+ sid = it.get("id") or uuid.uuid4().hex
1116
+ x = int(it.get("x", 10))
1117
+ y = int(it.get("y", 10))
1118
+ label = it.get("label") or self._default_label_for(cid)
1119
+
1120
+ self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
1121
+
1122
+ # If you also inline per-instance presets for normal shortcuts,
1123
+ # you can restore them here as well (omitted here for brevity).
1124
+
1125
+ # Persist shortcuts to QSettings
1126
+ self.save_shortcuts()
1127
+
1128
+ # NEW: Restore function bundle definitions + chips
1129
+ self._import_function_bundles_for_shortcuts(fb_payload, replace_existing=replace_existing)
1130
+
1131
+ return True, "OK"
1132
+ except Exception as e:
1133
+ try:
1134
+ self._debug(f"import_from_file FAILED: {e}")
1135
+ except Exception:
1136
+ pass
1137
+ return False, str(e)
1138
+
1139
+ def _icon_for_command(self, command_id: str, act: QAction | None) -> QIcon:
1140
+ # 1) Prefer the QAction icon (works for built-in tools and scripts if set)
1141
+ if act is not None:
1142
+ try:
1143
+ ico = act.icon()
1144
+ if ico is not None and not ico.isNull():
1145
+ return ico
1146
+ except Exception:
1147
+ pass
1148
+
1149
+ # 2) Optional: if the action carries a script_icon_path property, use it
1150
+ try:
1151
+ p = act.property("script_icon_path")
1152
+ if isinstance(p, str) and p.strip() and Path(p).exists():
1153
+ return QIcon(p.strip())
1154
+ except Exception:
1155
+ pass
1156
+
1157
+ # 3) Fallback for scripts: use the generic SCRIPT icon
1158
+ if isinstance(command_id, str) and command_id.startswith("script:"):
1159
+ try:
1160
+ ic = _get_icons()
1161
+ if ic is not None and hasattr(ic, "SCRIPT"):
1162
+ v = ic.SCRIPT
1163
+ return v if isinstance(v, QIcon) else QIcon(str(v))
1164
+ except Exception:
1165
+ pass
1166
+
1167
+ return QIcon()
1168
+
1169
+
1170
+ # ---------- utils ----------
1171
+ def _ensure_ext(self, path: str, ext: str) -> str:
1172
+ p = Path(path)
1173
+ if p.suffix.lower() != ext.lower():
1174
+ p = p.with_suffix(ext)
1175
+ return str(p)
1176
+
1177
+ # ---- CRUD for shortcuts --------------------------------------------
1178
+ def _default_label_for(self, command_id: str) -> str:
1179
+ act = self.registry.get(command_id)
1180
+ if not act:
1181
+ return command_id
1182
+ return (act.text() or act.toolTip() or command_id).strip() or command_id
1183
+
1184
+ def add_shortcut(self,
1185
+ command_id: str,
1186
+ pos: QPoint,
1187
+ *,
1188
+ label: Optional[str] = None,
1189
+ shortcut_id: Optional[str] = None):
1190
+ """
1191
+ Always creates a NEW instance (multiple per command_id allowed).
1192
+ """
1193
+ act = self.registry.get(command_id)
1194
+ if not act:
1195
+ return
1196
+
1197
+ sid = shortcut_id or uuid.uuid4().hex
1198
+ lbl = (label or self._default_label_for(command_id)).strip() or command_id
1199
+
1200
+ ico = self._icon_for_command(command_id, act)
1201
+ w = ShortcutButton(self, sid, command_id, ico, lbl, self.canvas)
1202
+ w.adjustSize()
1203
+ w.move(pos)
1204
+ w.show()
1205
+
1206
+ # when the C++ object dies, clean maps using the SID
1207
+ w.destroyed.connect(lambda _=None, sid=sid: self._on_widget_destroyed(sid))
1208
+
1209
+ self.widgets[sid] = w
1210
+ self.save_shortcuts()
1211
+
1212
+ def add_shortcut_from_payload(self, payload: dict, pos: QPoint):
1213
+ """
1214
+ Create a desktop shortcut from a full command payload
1215
+ (e.g. drag from History Explorer with command_id + preset).
1216
+ """
1217
+ if not isinstance(payload, dict):
1218
+ return
1219
+
1220
+ cid = payload.get("command_id") or payload.get("cid")
1221
+ if not isinstance(cid, str) or not cid:
1222
+ return
1223
+
1224
+ preset = payload.get("preset") or {}
1225
+ if not isinstance(preset, dict):
1226
+ try:
1227
+ preset = dict(preset)
1228
+ except Exception:
1229
+ preset = {}
1230
+
1231
+ # Normal shortcut creation
1232
+ sid = uuid.uuid4().hex
1233
+ label = self._default_label_for(cid)
1234
+ self.add_shortcut(cid, pos, label=label, shortcut_id=sid)
1235
+
1236
+ # Attach preset at instance-level (same mechanism as context menu)
1237
+ w = self.widgets.get(sid)
1238
+ if w and not _is_dead(w):
1239
+ try:
1240
+ w._save_preset(preset)
1241
+ except Exception:
1242
+ pass
1243
+
1244
+ # Persist layout + presets
1245
+ self.save_shortcuts()
1246
+
1247
+
1248
+ def update_label(self, shortcut_id: str, new_label: str):
1249
+ w = self.widgets.get(shortcut_id)
1250
+ if w and not _is_dead(w):
1251
+ w.setText(new_label.strip()) # in case caller didn't already
1252
+ self.save_shortcuts()
1253
+
1254
+
1255
+ # ---- persistence (QSettings JSON blob) ----
1256
+ def save_shortcuts(self):
1257
+ data = []
1258
+ for sid, w in list(self.widgets.items()):
1259
+ if _is_dead(w):
1260
+ self.widgets.pop(sid, None)
1261
+ self.selected.discard(sid)
1262
+ continue
1263
+ try:
1264
+ if not w.isVisible():
1265
+ continue
1266
+ p = w.pos()
1267
+ data.append({
1268
+ "id": sid,
1269
+ "command_id": w.command_id,
1270
+ "label": w.text(),
1271
+ "x": p.x(),
1272
+ "y": p.y(),
1273
+ })
1274
+ except RuntimeError:
1275
+ self.widgets.pop(sid, None)
1276
+ self.selected.discard(sid)
1277
+
1278
+ # Save new format and remove legacy
1279
+ self.settings.setValue(SET_KEY_V2, json.dumps(data))
1280
+ self.settings.remove(SET_KEY_V1)
1281
+ self.settings.sync()
1282
+
1283
+ def load_shortcuts(self):
1284
+ # try v2 first
1285
+ raw_v2 = self.settings.value(SET_KEY_V2, "", type=str) or ""
1286
+ if raw_v2:
1287
+ try:
1288
+ arr = json.loads(raw_v2)
1289
+ for entry in arr:
1290
+ sid = entry.get("id") or uuid.uuid4().hex
1291
+ cid = entry.get("command_id")
1292
+ x = int(entry.get("x", 10))
1293
+ y = int(entry.get("y", 10))
1294
+ label = entry.get("label") or self._default_label_for(cid)
1295
+ self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
1296
+ return
1297
+ except Exception as e:
1298
+ try:
1299
+ self.mw._log(f"Shortcuts v2: failed to load ({e})")
1300
+ except Exception:
1301
+ pass
1302
+
1303
+ # migrate v1 (positions only)
1304
+ raw_v1 = self.settings.value(SET_KEY_V1, "", type=str) or ""
1305
+ if not raw_v1:
1306
+ return
1307
+ try:
1308
+ arr = json.loads(raw_v1)
1309
+ for entry in arr:
1310
+ cid = entry.get("id") or entry.get("command_id") # old key was "id" = command_id
1311
+ x = int(entry.get("x", 10))
1312
+ y = int(entry.get("y", 10))
1313
+ # each old entry becomes its own instance
1314
+ sid = uuid.uuid4().hex
1315
+ label = self._default_label_for(cid)
1316
+ self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
1317
+ # after migrating, persist as v2
1318
+ self.save_shortcuts()
1319
+ except Exception as e:
1320
+ try:
1321
+ self.mw._log(f"Shortcuts v1: failed to migrate ({e})")
1322
+ except Exception:
1323
+ pass
1324
+
1325
+
1326
+ def _load_keybinds(self) -> dict:
1327
+ raw = self.settings.value(KEYBINDS_KEY, "", type=str) or ""
1328
+ if not raw:
1329
+ return {}
1330
+ try:
1331
+ obj = json.loads(raw)
1332
+ return obj if isinstance(obj, dict) else {}
1333
+ except Exception:
1334
+ return {}
1335
+
1336
+ def _save_keybinds(self, d: dict):
1337
+ self.settings.setValue(KEYBINDS_KEY, json.dumps(d))
1338
+ self.settings.sync()
1339
+
1340
+ def set_keybind(self, command_id: str, keyseq: str | None):
1341
+ """
1342
+ keyseq: e.g. "Ctrl+Alt+K". If None/empty => clear binding.
1343
+ Applies immediately if action is registered.
1344
+ """
1345
+ d = self._load_keybinds()
1346
+ if keyseq and keyseq.strip():
1347
+ d[command_id] = keyseq.strip()
1348
+ else:
1349
+ d.pop(command_id, None)
1350
+ self._save_keybinds(d)
1351
+
1352
+ act = self.registry.get(command_id)
1353
+ if act is not None:
1354
+ if keyseq and keyseq.strip():
1355
+ act.setShortcut(QKeySequence(keyseq.strip()))
1356
+ act.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
1357
+ else:
1358
+ act.setShortcut(QKeySequence())
1359
+
1360
+ def apply_command_to_subwindow(self, subwin, payload):
1361
+ """Apply a dragged command (or bundle) to the specific subwindow."""
1362
+ from PyQt6.QtWidgets import QApplication
1363
+
1364
+ # --- normalize payload to a dict ---
1365
+ if isinstance(payload, (bytes, bytearray)):
1366
+ try:
1367
+ payload = json.loads(payload.decode("utf-8"))
1368
+ except Exception:
1369
+ print("[Shortcuts] apply_command_to_subwindow: invalid bytes payload", flush=True)
1370
+ QApplication.processEvents()
1371
+ return
1372
+ if not isinstance(payload, dict):
1373
+ print(f"[Shortcuts] apply_command_to_subwindow: non-dict payload {type(payload)}", flush=True)
1374
+ QApplication.processEvents()
1375
+ return
1376
+
1377
+ print(f"[Shortcuts] apply_command_to_subwindow: subwin={subwin}, payload={payload!r}", flush=True)
1378
+ QApplication.processEvents()
1379
+
1380
+ # --- flatten accidental nesting:
1381
+ cid = payload.get("command_id")
1382
+ if isinstance(cid, dict):
1383
+ payload = cid
1384
+ cid = payload.get("command_id")
1385
+
1386
+ if not isinstance(cid, str) and isinstance(payload.get("command_id"), dict):
1387
+ payload = payload["command_id"]
1388
+ cid = payload.get("command_id")
1389
+
1390
+ if not isinstance(cid, str) or not cid:
1391
+ print("[Shortcuts] apply_command_to_subwindow: no valid command_id", flush=True)
1392
+ QApplication.processEvents()
1393
+ return
1394
+
1395
+ # --- function bundle handling ---
1396
+ if cid == "function_bundle":
1397
+ steps = payload.get("steps")
1398
+ inherit_target = bool(payload.get("inherit_target", True))
1399
+ print(f"[Shortcuts] function_bundle: {len(steps or [])} step(s), inherit_target={inherit_target}", flush=True)
1400
+ QApplication.processEvents()
1401
+
1402
+ # If explicit steps are present (chip DnD / history replay payload),
1403
+ # run them INLINE via the normal command path (same as FunctionBundleDialog).
1404
+ if isinstance(steps, list) and steps:
1405
+ for i, st in enumerate(steps, start=1):
1406
+ try:
1407
+ scid = st.get("command_id")
1408
+ except Exception:
1409
+ scid = None
1410
+ print(f"[Shortcuts] inline step {i}/{len(steps)} → {scid!r}", flush=True)
1411
+ QApplication.processEvents()
1412
+ # Reuse the same target subwindow for each step
1413
+ self.apply_command_to_subwindow(subwin, st)
1414
+ return
1415
+
1416
+ # No inline steps → this is a true 'function_bundle' command (e.g. from Scripts),
1417
+ # so delegate to the central executor.
1418
+ try:
1419
+ from setiastro.saspro.function_bundle import run_function_bundle_command
1420
+ print("[Shortcuts] function_bundle: using run_function_bundle_command (no inline steps)", flush=True)
1421
+ QApplication.processEvents()
1422
+
1423
+ try:
1424
+ self.mdi.setActiveSubWindow(subwin)
1425
+ except Exception:
1426
+ pass
1427
+
1428
+ # Pass the whole payload as cfg so things like bundle_key, etc., are available.
1429
+ cfg = dict(payload)
1430
+ try:
1431
+ run_function_bundle_command(self.mw, preset=payload.get("preset") or None, cfg=cfg)
1432
+ except TypeError:
1433
+ # older signature: (ctx, cfg)
1434
+ run_function_bundle_command(self.mw, cfg)
1435
+ print("[Shortcuts] function_bundle: run_function_bundle_command finished", flush=True)
1436
+ QApplication.processEvents()
1437
+ return
1438
+ except Exception as ex:
1439
+ print(f"[Shortcuts] function_bundle: FAILED in central executor: {ex!r}", flush=True)
1440
+ QApplication.processEvents()
1441
+ return
1442
+
1443
+ # --- primary path (unchanged) ---
1444
+ mw = self.mw
1445
+ try:
1446
+ if hasattr(mw, "_handle_command_drop"):
1447
+ print(f"[Shortcuts] forwarding cid={cid!r} to _handle_command_drop", flush=True)
1448
+ QApplication.processEvents()
1449
+ mw._handle_command_drop(payload, target_sw=subwin)
1450
+ return
1451
+ except Exception as ex:
1452
+ print(f"[Shortcuts] _handle_command_drop raised: {ex!r}, falling through", flush=True)
1453
+ QApplication.processEvents()
1454
+
1455
+ # --- secondary paths (unchanged) ---
1456
+ w = getattr(subwin, "widget", None)
1457
+ target = w() if callable(w) else w
1458
+ preset = payload.get("preset") or {}
1459
+
1460
+ if hasattr(target, "apply_command"):
1461
+ print(f"[Shortcuts] target.apply_command for cid={cid!r}", flush=True)
1462
+ QApplication.processEvents()
1463
+ target.apply_command(cid, preset)
1464
+ return
1465
+ if hasattr(mw, "apply_command_to_view"):
1466
+ print(f"[Shortcuts] mw.apply_command_to_view for cid={cid!r}", flush=True)
1467
+ QApplication.processEvents()
1468
+ mw.apply_command_to_view(target, cid, preset)
1469
+ return
1470
+ if hasattr(mw, "run_command"):
1471
+ print(f"[Shortcuts] mw.run_command for cid={cid!r}", flush=True)
1472
+ QApplication.processEvents()
1473
+ mw.run_command(cid, preset, view=target)
1474
+ return
1475
+
1476
+ print(f"[Shortcuts] fallback QAction trigger for cid={cid!r}", flush=True)
1477
+ QApplication.processEvents()
1478
+ self.mdi.setActiveSubWindow(subwin)
1479
+ act = self.registry.get(cid if isinstance(cid, str) else str(cid))
1480
+ if act:
1481
+ act.trigger()
1482
+
1483
+
1484
+ # ---------- selection ----------
1485
+ def _apply_sel_visual(self, sid: str, on: bool):
1486
+ w = self.widgets.get(sid)
1487
+ if _is_dead(w):
1488
+ # Clean up any stale references
1489
+ self.widgets.pop(sid, None)
1490
+ self.selected.discard(sid)
1491
+ return
1492
+ try:
1493
+ if on:
1494
+ w.setStyleSheet("QToolButton { border: 2px solid #4da3ff; border-radius: 6px; padding: 2px; }")
1495
+ else:
1496
+ w.setStyleSheet("")
1497
+ except RuntimeError:
1498
+ # C++ object died between get() and call
1499
+ self.widgets.pop(sid, None)
1500
+ self.selected.discard(sid)
1501
+
1502
+ def select_only(self, sid: str):
1503
+ self.clear_selection()
1504
+ self.selected.add(sid)
1505
+ self._apply_sel_visual(sid, True)
1506
+
1507
+ def toggle_select(self, sid: str):
1508
+ if sid in self.selected:
1509
+ self.selected.remove(sid)
1510
+ self._apply_sel_visual(sid, False)
1511
+ else:
1512
+ self.selected.add(sid)
1513
+ self._apply_sel_visual(sid, True)
1514
+
1515
+ def select_in_rect(self, rect: QRect, *, mode: str = "replace"):
1516
+ if mode == "replace":
1517
+ self.clear_selection()
1518
+ for sid, w in list(self.widgets.items()):
1519
+ if _is_dead(w):
1520
+ self.widgets.pop(sid, None)
1521
+ self.selected.discard(sid)
1522
+ continue
1523
+ if rect.intersects(w.geometry()):
1524
+ if sid not in self.selected:
1525
+ self.selected.add(sid)
1526
+ self._apply_sel_visual(sid, True)
1527
+
1528
+ def selected_widgets(self):
1529
+ out = []
1530
+ for sid in list(self.selected):
1531
+ w = self.widgets.get(sid)
1532
+ if _is_dead(w):
1533
+ self.widgets.pop(sid, None)
1534
+ self.selected.discard(sid)
1535
+ continue
1536
+ out.append(w)
1537
+ return out
1538
+
1539
+ def clear_selection(self):
1540
+ """Clear current selection highlight without deleting shortcuts."""
1541
+ # Remove highlight from all currently selected items
1542
+ for sid in list(self.selected):
1543
+ try:
1544
+ self._apply_sel_visual(sid, False)
1545
+ except Exception:
1546
+ pass
1547
+ self.selected.clear()
1548
+
1549
+ # Nudge repaint (optional but helps)
1550
+ try:
1551
+ self.canvas.update()
1552
+ except Exception:
1553
+ pass
1554
+
1555
+
1556
+ def clear(self):
1557
+ for sid, w in list(self.widgets.items()):
1558
+ try:
1559
+ if not _is_dead(w):
1560
+ w.hide()
1561
+ try:
1562
+ w.setParent(None) # ← detach from canvas immediately
1563
+ except Exception:
1564
+ pass
1565
+ w.deleteLater()
1566
+ except RuntimeError:
1567
+ pass
1568
+ self.widgets.clear()
1569
+ self.selected.clear()
1570
+ self.settings.setValue(SET_KEY_V2, "[]")
1571
+ self.settings.remove(SET_KEY_V1)
1572
+ self.settings.sync()
1573
+ try:
1574
+ self.canvas.update() # nudge repaint
1575
+ except Exception:
1576
+ pass
1577
+
1578
+
1579
+ # ---------- group move / delete ----------
1580
+ def _group_bounds(self) -> QRect:
1581
+ rect = None
1582
+ for w in self.selected_widgets():
1583
+ rect = w.geometry() if rect is None else rect.united(w.geometry())
1584
+ return rect if rect is not None else QRect()
1585
+
1586
+ def move_selected_by(self, dx: int, dy: int):
1587
+ if not self.selected:
1588
+ return
1589
+ # clamp whole group to canvas bounds so relative spacing stays intact
1590
+ group = self._group_bounds()
1591
+ vp = self.canvas.rect()
1592
+ min_dx = vp.left() - group.left()
1593
+ max_dx = vp.right() - group.right()
1594
+ min_dy = vp.top() - group.top()
1595
+ max_dy = vp.bottom()- group.bottom()
1596
+ dx = max(min_dx, min(dx, max_dx))
1597
+ dy = max(min_dy, min(dy, max_dy))
1598
+ if dx == 0 and dy == 0:
1599
+ return
1600
+ for w in self.selected_widgets():
1601
+ g = w.geometry()
1602
+ g.translate(dx, dy)
1603
+ w.setGeometry(g)
1604
+
1605
+ def delete_by_id(self, sid: str, *, persist: bool = True):
1606
+ self.selected.discard(sid)
1607
+ w = self.widgets.pop(sid, None)
1608
+ if not _is_dead(w):
1609
+ try:
1610
+ w.hide()
1611
+ except RuntimeError:
1612
+ pass
1613
+ try:
1614
+ w.deleteLater()
1615
+ except RuntimeError:
1616
+ pass
1617
+ if persist:
1618
+ self.save_shortcuts()
1619
+
1620
+ def delete_selected(self):
1621
+ # bulk delete, then persist once
1622
+ for sid in list(self.selected):
1623
+ self.delete_by_id(sid, persist=False)
1624
+ self.selected.clear()
1625
+ self.save_shortcuts()
1626
+
1627
+ def remove(self, sid: str):
1628
+ # legacy single-remove (kept for callers)
1629
+ self.delete_by_id(sid, persist=True)
1630
+
1631
+
1632
+ class _StatStretchPresetDialog(QDialog):
1633
+ def __init__(self, parent=None, initial: dict | None = None):
1634
+ super().__init__(parent)
1635
+ self.setWindowTitle("Statistical Stretch — Preset")
1636
+ init = dict(initial or {})
1637
+
1638
+ self.spin_target = QDoubleSpinBox()
1639
+ self.spin_target.setRange(0.0, 1.0); self.spin_target.setDecimals(3)
1640
+ self.spin_target.setSingleStep(0.01)
1641
+ self.spin_target.setValue(float(init.get("target_median", 0.25)))
1642
+
1643
+ self.chk_linked = QCheckBox("Linked RGB channels")
1644
+ self.chk_linked.setChecked(bool(init.get("linked", False)))
1645
+
1646
+ self.chk_normalize = QCheckBox("Normalize to [0..1]")
1647
+ self.chk_normalize.setChecked(bool(init.get("normalize", False)))
1648
+
1649
+ self.spin_curves = QDoubleSpinBox()
1650
+ self.spin_curves.setRange(0.0, 1.0); self.spin_curves.setDecimals(2)
1651
+ self.spin_curves.setSingleStep(0.05)
1652
+ self.spin_curves.setValue(float(init.get("curves_boost", 0.0 if not init.get("apply_curves") else 0.20)))
1653
+
1654
+ form = QFormLayout(self)
1655
+ form.addRow("Target median:", self.spin_target)
1656
+ form.addRow("", self.chk_linked)
1657
+ form.addRow("", self.chk_normalize)
1658
+ form.addRow("Curves boost (0–1):", self.spin_curves)
1659
+ form.addRow(QLabel("Curves are applied only if boost > 0."))
1660
+
1661
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1662
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
1663
+ form.addRow(btns)
1664
+
1665
+ def result_dict(self) -> dict:
1666
+ boost = float(self.spin_curves.value())
1667
+ return {
1668
+ "target_median": float(self.spin_target.value()),
1669
+ "linked": bool(self.chk_linked.isChecked()),
1670
+ "normalize": bool(self.chk_normalize.isChecked()),
1671
+ "apply_curves": bool(boost > 0.0),
1672
+ "curves_boost": boost,
1673
+ }
1674
+
1675
+
1676
+ class _StarStretchPresetDialog(QDialog):
1677
+ def __init__(self, parent=None, initial: dict | None = None):
1678
+ super().__init__(parent)
1679
+ self.setWindowTitle("Star Stretch — Preset")
1680
+ init = dict(initial or {})
1681
+
1682
+ self.spin_amount = QDoubleSpinBox()
1683
+ self.spin_amount.setRange(0.0, 8.0); self.spin_amount.setDecimals(2)
1684
+ self.spin_amount.setSingleStep(0.05)
1685
+ self.spin_amount.setValue(float(init.get("stretch_factor", 5.00)))
1686
+
1687
+ self.spin_sat = QDoubleSpinBox()
1688
+ self.spin_sat.setRange(0.0, 2.0); self.spin_sat.setDecimals(2)
1689
+ self.spin_sat.setSingleStep(0.05)
1690
+ self.spin_sat.setValue(float(init.get("color_boost", 1.00)))
1691
+
1692
+ self.chk_scnr = QCheckBox("Remove Green via SCNR")
1693
+ self.chk_scnr.setChecked(bool(init.get("scnr_green", False)))
1694
+
1695
+ form = QFormLayout(self)
1696
+ form.addRow("Stretch amount (0–8):", self.spin_amount)
1697
+ form.addRow("Color boost (0–2):", self.spin_sat)
1698
+ form.addRow("", self.chk_scnr)
1699
+
1700
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1701
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
1702
+ form.addRow(btns)
1703
+
1704
+ def result_dict(self) -> dict:
1705
+ return {
1706
+ "stretch_factor": float(self.spin_amount.value()), # 0..8
1707
+ "color_boost": float(self.spin_sat.value()), # 0..2
1708
+ "scnr_green": bool(self.chk_scnr.isChecked()),
1709
+ }
1710
+
1711
+ class _RemoveGreenPresetDialog(QDialog):
1712
+ def __init__(self, parent=None, initial: dict | None = None):
1713
+ super().__init__(parent)
1714
+ self.setWindowTitle("Remove Green — Preset")
1715
+ init = dict(initial or {})
1716
+
1717
+ # Local labels so there’s no external dependency.
1718
+ MODE_LABELS = {
1719
+ "avg": "Average neutral (G → min(avg(R,B), G))",
1720
+ "max": "Average neutral MAX (G → min(max(R,B), G))",
1721
+ "min": "Average neutral MIN (G → min(min(R,B), G))",
1722
+ }
1723
+ MODE_INDEX = {"avg": 0, "max": 1, "min": 2}
1724
+
1725
+ # Amount
1726
+ self.spin_amount = QDoubleSpinBox()
1727
+ self.spin_amount.setRange(0.0, 1.0)
1728
+ self.spin_amount.setDecimals(2)
1729
+ self.spin_amount.setSingleStep(0.05)
1730
+ self.spin_amount.setValue(float(init.get("amount", 1.00))) # default full SCNR
1731
+
1732
+ # Mode
1733
+ self.combo_mode = QComboBox()
1734
+ self.combo_mode.addItem(MODE_LABELS["avg"], userData="avg")
1735
+ self.combo_mode.addItem(MODE_LABELS["max"], userData="max")
1736
+ self.combo_mode.addItem(MODE_LABELS["min"], userData="min")
1737
+ init_mode = str(init.get("mode", init.get("neutral_mode", "avg"))).lower()
1738
+ self.combo_mode.setCurrentIndex(MODE_INDEX.get(init_mode, 0))
1739
+
1740
+ # Preserve lightness
1741
+ self.cb_preserve = QCheckBox("Preserve lightness")
1742
+ self.cb_preserve.setChecked(bool(init.get("preserve_lightness", init.get("preserve", True))))
1743
+
1744
+ # Layout
1745
+ form = QFormLayout(self)
1746
+ form.addRow("Amount (0–1):", self.spin_amount)
1747
+ form.addRow("Neutral mode:", self.combo_mode)
1748
+ form.addRow("", self.cb_preserve)
1749
+
1750
+ btns = QDialogButtonBox(
1751
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
1752
+ parent=self
1753
+ )
1754
+ btns.accepted.connect(self.accept)
1755
+ btns.rejected.connect(self.reject)
1756
+ form.addRow(btns)
1757
+
1758
+ def result_dict(self) -> dict:
1759
+ return {
1760
+ "amount": float(self.spin_amount.value()), # 0..1
1761
+ "mode": self.combo_mode.currentData() or "avg", # "avg" | "max" | "min"
1762
+ "preserve_lightness": bool(self.cb_preserve.isChecked()), # True/False
1763
+ }
1764
+
1765
+
1766
+ class _BackgroundNeutralPresetDialog(QDialog):
1767
+ """
1768
+ Preset UI for Background Neutralization:
1769
+ • Mode: Auto (default) or Rectangle
1770
+ • Rect (normalized): x, y, w, h in [0..1]
1771
+ """
1772
+ def __init__(self, parent=None, initial: dict | None = None):
1773
+ super().__init__(parent)
1774
+ self.setWindowTitle("Background Neutralization — Preset")
1775
+ init = dict(initial or {})
1776
+
1777
+ # Mode radios
1778
+ self.radio_auto = QRadioButton("Auto (50×50 finder)")
1779
+ self.radio_rect = QRadioButton("Rectangle (normalized coords)")
1780
+ mode = (init.get("mode") or "auto").lower()
1781
+ if mode == "rect":
1782
+ self.radio_rect.setChecked(True)
1783
+ else:
1784
+ self.radio_auto.setChecked(True)
1785
+
1786
+ # Rect spinboxes (normalized 0..1)
1787
+ rn = init.get("rect_norm") or [0.40, 0.60, 0.08, 0.06]
1788
+ self.spin_x = QDoubleSpinBox(); self._cfg_norm_box(self.spin_x, rn[0])
1789
+ self.spin_y = QDoubleSpinBox(); self._cfg_norm_box(self.spin_y, rn[1])
1790
+ self.spin_w = QDoubleSpinBox(); self._cfg_norm_box(self.spin_w, rn[2])
1791
+ self.spin_h = QDoubleSpinBox(); self._cfg_norm_box(self.spin_h, rn[3])
1792
+
1793
+ form = QFormLayout(self)
1794
+ form.addRow(self.radio_auto)
1795
+ form.addRow(self.radio_rect)
1796
+ form.addRow("x (0..1):", self.spin_x)
1797
+ form.addRow("y (0..1):", self.spin_y)
1798
+ form.addRow("w (0..1):", self.spin_w)
1799
+ form.addRow("h (0..1):", self.spin_h)
1800
+
1801
+ # Enable/disable rect fields based on mode
1802
+ self.radio_auto.toggled.connect(self._update_enabled)
1803
+ self.radio_rect.toggled.connect(self._update_enabled)
1804
+ self._update_enabled()
1805
+
1806
+ btns = QDialogButtonBox(
1807
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
1808
+ parent=self
1809
+ )
1810
+ btns.accepted.connect(self.accept)
1811
+ btns.rejected.connect(self.reject)
1812
+ form.addRow(btns)
1813
+
1814
+ def _cfg_norm_box(self, box: QDoubleSpinBox, val: float):
1815
+ box.setRange(0.0, 1.0)
1816
+ box.setDecimals(3)
1817
+ box.setSingleStep(0.01)
1818
+ try:
1819
+ box.setValue(float(val))
1820
+ except Exception:
1821
+ box.setValue(0.0)
1822
+
1823
+ def _update_enabled(self):
1824
+ on = self.radio_rect.isChecked()
1825
+ for w in (self.spin_x, self.spin_y, self.spin_w, self.spin_h):
1826
+ w.setEnabled(on)
1827
+
1828
+ def result_dict(self) -> dict:
1829
+ if self.radio_auto.isChecked():
1830
+ return {"mode": "auto"}
1831
+ # sanitize/cap in [0,1]
1832
+ x = max(0.0, min(1.0, float(self.spin_x.value())))
1833
+ y = max(0.0, min(1.0, float(self.spin_y.value())))
1834
+ w = max(0.0, min(1.0, float(self.spin_w.value())))
1835
+ h = max(0.0, min(1.0, float(self.spin_h.value())))
1836
+ # ensure at least a 1e-6 nonzero footprint so integer rounding later doesn't zero-out
1837
+ if w == 0.0: w = 1e-6
1838
+ if h == 0.0: h = 1e-6
1839
+ return {"mode": "rect", "rect_norm": [x, y, w, h]}
1840
+
1841
+ class _WhiteBalancePresetDialog(QDialog):
1842
+ def __init__(self, parent=None, initial: dict | None = None):
1843
+ super().__init__(parent)
1844
+ self.setWindowTitle("White Balance — Preset")
1845
+ init = dict(initial or {})
1846
+
1847
+ v = QVBoxLayout(self)
1848
+
1849
+ # Mode
1850
+ row = QHBoxLayout()
1851
+ row.addWidget(QLabel("Mode:"))
1852
+ self.mode = QComboBox()
1853
+ self.mode.addItems(["Star-Based", "Manual", "Auto"])
1854
+ m = (init.get("mode") or "star").lower()
1855
+ if m == "manual": self.mode.setCurrentText("Manual")
1856
+ elif m == "auto": self.mode.setCurrentText("Auto")
1857
+ else: self.mode.setCurrentText("Star-Based")
1858
+ row.addWidget(self.mode); row.addStretch()
1859
+ v.addLayout(row)
1860
+
1861
+ # Star options
1862
+ self.grp_star = QGroupBox("Star-Based")
1863
+ sv = QGridLayout(self.grp_star)
1864
+ self.spin_thr = QDoubleSpinBox(); self.spin_thr.setRange(0.5, 200.0); self.spin_thr.setDecimals(1)
1865
+ self.spin_thr.setSingleStep(0.5); self.spin_thr.setValue(float(init.get("threshold", 50.0)))
1866
+ self.chk_reuse = QCheckBox("Reuse cached detections"); self.chk_reuse.setChecked(bool(init.get("reuse_cached_sources", True)))
1867
+ sv.addWidget(QLabel("Threshold (σ):"), 0, 0); sv.addWidget(self.spin_thr, 0, 1)
1868
+ sv.addWidget(self.chk_reuse, 1, 0, 1, 2)
1869
+ v.addWidget(self.grp_star)
1870
+
1871
+ # Manual options
1872
+ self.grp_manual = QGroupBox("Manual")
1873
+ gv = QGridLayout(self.grp_manual)
1874
+ self.r = QDoubleSpinBox(); self._cfg_gain(self.r, float(init.get("r_gain", 1.0)))
1875
+ self.g = QDoubleSpinBox(); self._cfg_gain(self.g, float(init.get("g_gain", 1.0)))
1876
+ self.b = QDoubleSpinBox(); self._cfg_gain(self.b, float(init.get("b_gain", 1.0)))
1877
+ gv.addWidget(QLabel("Red gain:"), 0, 0); gv.addWidget(self.r, 0, 1)
1878
+ gv.addWidget(QLabel("Green gain:"), 1, 0); gv.addWidget(self.g, 1, 1)
1879
+ gv.addWidget(QLabel("Blue gain:"), 2, 0); gv.addWidget(self.b, 2, 1)
1880
+ v.addWidget(self.grp_manual)
1881
+
1882
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1883
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
1884
+ v.addWidget(btns)
1885
+
1886
+ self.mode.currentTextChanged.connect(self._refresh)
1887
+ self._refresh()
1888
+
1889
+ def _cfg_gain(self, box: QDoubleSpinBox, val: float):
1890
+ box.setRange(0.5, 1.5); box.setDecimals(3); box.setSingleStep(0.01); box.setValue(val)
1891
+
1892
+ def _refresh(self):
1893
+ t = self.mode.currentText()
1894
+ self.grp_star.setVisible(t == "Star-Based")
1895
+ self.grp_manual.setVisible(t == "Manual")
1896
+
1897
+ def result_dict(self) -> dict:
1898
+ t = self.mode.currentText()
1899
+ if t == "Manual":
1900
+ return {"mode": "manual", "r_gain": float(self.r.value()), "g_gain": float(self.g.value()), "b_gain": float(self.b.value())}
1901
+ if t == "Auto":
1902
+ return {"mode": "auto"}
1903
+ return {"mode": "star", "threshold": float(self.spin_thr.value()), "reuse_cached_sources": bool(self.chk_reuse.isChecked())}
1904
+
1905
+
1906
+ class _WaveScaleHDRPresetDialog(QDialog):
1907
+ """
1908
+ Preset UI for WaveScale HDR:
1909
+ • n_scales (2..10)
1910
+ • compression_factor (0.10..5.00)
1911
+ • mask_gamma (0.10..10.00)
1912
+ • decay_rate (0.10..1.00)
1913
+ • optional dim_gamma (enable to store; omit to use auto)
1914
+ """
1915
+ def __init__(self, parent=None, initial: dict | None = None):
1916
+ super().__init__(parent)
1917
+ self.setWindowTitle("WaveScale HDR — Preset")
1918
+ init = dict(initial or {})
1919
+
1920
+ form = QFormLayout(self)
1921
+
1922
+ self.sp_scales = QSpinBox()
1923
+ self.sp_scales.setRange(2, 10)
1924
+ self.sp_scales.setValue(int(init.get("n_scales", 5)))
1925
+
1926
+ self.dp_comp = QDoubleSpinBox()
1927
+ self.dp_comp.setRange(0.10, 5.00)
1928
+ self.dp_comp.setDecimals(2)
1929
+ self.dp_comp.setSingleStep(0.05)
1930
+ self.dp_comp.setValue(float(init.get("compression_factor", 1.50)))
1931
+
1932
+ self.dp_gamma = QDoubleSpinBox()
1933
+ self.dp_gamma.setRange(0.10, 10.00)
1934
+ self.dp_gamma.setDecimals(2)
1935
+ self.dp_gamma.setSingleStep(0.05)
1936
+ # matches slider default of 500 → 5.00
1937
+ self.dp_gamma.setValue(float(init.get("mask_gamma", 5.00)))
1938
+
1939
+ self.dp_decay = QDoubleSpinBox()
1940
+ self.dp_decay.setRange(0.10, 1.00)
1941
+ self.dp_decay.setDecimals(2)
1942
+ self.dp_decay.setSingleStep(0.05)
1943
+ self.dp_decay.setValue(float(init.get("decay_rate", 0.50)))
1944
+
1945
+ # Optional dim gamma
1946
+ row_dim = QHBoxLayout()
1947
+ self.chk_dim = QCheckBox("Use custom dim γ")
1948
+ self.dp_dim = QDoubleSpinBox()
1949
+ self.dp_dim.setRange(0.10, 6.00)
1950
+ self.dp_dim.setDecimals(2)
1951
+ self.dp_dim.setSingleStep(0.05)
1952
+ self.dp_dim.setValue(float(init.get("dim_gamma", 2.00)))
1953
+ if "dim_gamma" in init:
1954
+ self.chk_dim.setChecked(True)
1955
+ self.dp_dim.setEnabled(self.chk_dim.isChecked())
1956
+ self.chk_dim.toggled.connect(self.dp_dim.setEnabled)
1957
+ row_dim.addWidget(self.chk_dim)
1958
+ row_dim.addWidget(self.dp_dim, 1)
1959
+
1960
+ form.addRow("Number of scales:", self.sp_scales)
1961
+ form.addRow("Coarse compression:", self.dp_comp)
1962
+ form.addRow("Mask gamma:", self.dp_gamma)
1963
+ form.addRow("Decay rate:", self.dp_decay)
1964
+ form.addRow("Dimming:", row_dim)
1965
+
1966
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
1967
+ QDialogButtonBox.StandardButton.Cancel, parent=self)
1968
+ btns.accepted.connect(self.accept)
1969
+ btns.rejected.connect(self.reject)
1970
+ form.addRow(btns)
1971
+
1972
+ def result_dict(self) -> dict:
1973
+ out = {
1974
+ "n_scales": int(self.sp_scales.value()),
1975
+ "compression_factor": float(self.dp_comp.value()),
1976
+ "mask_gamma": float(self.dp_gamma.value()),
1977
+ "decay_rate": float(self.dp_decay.value()),
1978
+ }
1979
+ if self.chk_dim.isChecked():
1980
+ out["dim_gamma"] = float(self.dp_dim.value()) # you said you'll add this param
1981
+ return out
1982
+
1983
+ class _WaveScaleDarkEnhancerPresetDialog(QDialog):
1984
+ """
1985
+ Preset UI for WaveScale Dark Enhancer:
1986
+ • n_scales (2–10)
1987
+ • boost_factor (0.10–10.00)
1988
+ • mask_gamma (0.10–10.00)
1989
+ • iterations (1–10)
1990
+ """
1991
+ def __init__(self, parent=None, initial: dict | None = None):
1992
+ super().__init__(parent)
1993
+ self.setWindowTitle("WaveScale Dark Enhancer — Preset")
1994
+ init = dict(initial or {})
1995
+
1996
+ form = QFormLayout(self)
1997
+
1998
+ self.sp_scales = QSpinBox(); self.sp_scales.setRange(2, 10); self.sp_scales.setValue(int(init.get("n_scales", 6)))
1999
+ self.dp_boost = QDoubleSpinBox(); self.dp_boost.setRange(0.10, 10.00); self.dp_boost.setDecimals(2); self.dp_boost.setSingleStep(0.05)
2000
+ self.dp_boost.setValue(float(init.get("boost_factor", 5.00)))
2001
+ self.dp_gamma = QDoubleSpinBox(); self.dp_gamma.setRange(0.10, 10.00); self.dp_gamma.setDecimals(2); self.dp_gamma.setSingleStep(0.05)
2002
+ self.dp_gamma.setValue(float(init.get("mask_gamma", 1.00)))
2003
+ self.sp_iters = QSpinBox(); self.sp_iters.setRange(1, 10); self.sp_iters.setValue(int(init.get("iterations", 2)))
2004
+
2005
+ form.addRow("Number of scales:", self.sp_scales)
2006
+ form.addRow("Boost factor:", self.dp_boost)
2007
+ form.addRow("Mask gamma:", self.dp_gamma)
2008
+ form.addRow("Iterations:", self.sp_iters)
2009
+
2010
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
2011
+ QDialogButtonBox.StandardButton.Cancel,
2012
+ parent=self)
2013
+ btns.accepted.connect(self.accept)
2014
+ btns.rejected.connect(self.reject)
2015
+ form.addRow(btns)
2016
+
2017
+ def result_dict(self) -> dict:
2018
+ return {
2019
+ "n_scales": int(self.sp_scales.value()),
2020
+ "boost_factor": float(self.dp_boost.value()),
2021
+ "mask_gamma": float(self.dp_gamma.value()),
2022
+ "iterations": int(self.sp_iters.value()),
2023
+ }
2024
+
2025
+ class _CLAHEPresetDialog(QDialog):
2026
+ """
2027
+ Preset UI for CLAHE:
2028
+ • clip_limit (0.10–4.00)
2029
+ • tile_px (8–512 px) → converted to OpenCV tileGridSize based on image size
2030
+ """
2031
+ def __init__(self, parent=None, initial: dict | None = None):
2032
+ super().__init__(parent)
2033
+ self.setWindowTitle("CLAHE — Preset")
2034
+ init = dict(initial or {})
2035
+
2036
+ form = QFormLayout(self)
2037
+
2038
+ self.dp_clip = QDoubleSpinBox()
2039
+ self.dp_clip.setRange(0.10, 4.00)
2040
+ self.dp_clip.setDecimals(2)
2041
+ self.dp_clip.setSingleStep(0.10)
2042
+ self.dp_clip.setValue(float(init.get("clip_limit", 2.00)))
2043
+
2044
+ self.sp_tile_px = QSpinBox()
2045
+ self.sp_tile_px.setRange(8, 512)
2046
+ self.sp_tile_px.setSingleStep(8)
2047
+
2048
+ # Support both old and new in the UI:
2049
+ if "tile_px" in init:
2050
+ self.sp_tile_px.setValue(int(init.get("tile_px", 128)))
2051
+ else:
2052
+ # legacy tile count → rough px guess; keeps old presets "reasonable"
2053
+ legacy_tile = int(init.get("tile", 8))
2054
+ legacy_tile = max(2, min(legacy_tile, 128))
2055
+ # Heuristic: convert tile count to a "typical" px size assuming ~2048 min dim
2056
+ px_guess = int(round(2048 / float(legacy_tile)))
2057
+ px_guess = max(8, min(px_guess, 512))
2058
+ # snap to step 8
2059
+ px_guess = int(round(px_guess / 8)) * 8
2060
+ self.sp_tile_px.setValue(px_guess)
2061
+
2062
+ form.addRow("Clip limit:", self.dp_clip)
2063
+ form.addRow("Tile size (px):", self.sp_tile_px)
2064
+
2065
+ btns = QDialogButtonBox(
2066
+ QDialogButtonBox.StandardButton.Ok |
2067
+ QDialogButtonBox.StandardButton.Cancel,
2068
+ parent=self
2069
+ )
2070
+ btns.accepted.connect(self.accept)
2071
+ btns.rejected.connect(self.reject)
2072
+ form.addRow(btns)
2073
+
2074
+ def result_dict(self) -> dict:
2075
+ return {
2076
+ "clip_limit": float(self.dp_clip.value()),
2077
+ "tile_px": int(self.sp_tile_px.value()),
2078
+ }
2079
+
2080
+ class _MorphologyPresetDialog(QDialog):
2081
+ def __init__(self, parent=None, initial: dict | None = None):
2082
+ super().__init__(parent)
2083
+ self.setWindowTitle("Morphology — Preset")
2084
+ init = dict(initial or {})
2085
+
2086
+ form = QFormLayout(self)
2087
+
2088
+ self.op = QComboBox()
2089
+ self.op.addItems(["Erosion", "Dilation", "Opening", "Closing"])
2090
+ op = (init.get("operation","erosion") or "erosion").lower()
2091
+ idx = {"erosion":0,"dilation":1,"opening":2,"closing":3}.get(op,0)
2092
+ self.op.setCurrentIndex(idx)
2093
+
2094
+ self.k = QSpinBox(); self.k.setRange(1,31); self.k.setSingleStep(2)
2095
+ kv = int(init.get("kernel", 3)); self.k.setValue(kv if kv%2==1 else kv+1)
2096
+
2097
+ self.it = QSpinBox(); self.it.setRange(1,10); self.it.setValue(int(init.get("iterations",1)))
2098
+
2099
+ form.addRow("Operation:", self.op)
2100
+ form.addRow("Kernel size (odd):", self.k)
2101
+ form.addRow("Iterations:", self.it)
2102
+
2103
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2104
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2105
+ form.addRow(btns)
2106
+
2107
+ def result_dict(self) -> dict:
2108
+ op = ["erosion","dilation","opening","closing"][self.op.currentIndex()]
2109
+ k = int(self.k.value()); k = k if k%2==1 else k+1
2110
+ it = int(self.it.value())
2111
+ return {"operation": op, "kernel": k, "iterations": it}
2112
+
2113
+ class _PixelMathPresetDialog(QDialog):
2114
+ def __init__(self, parent=None, initial: dict | None = None):
2115
+ super().__init__(parent)
2116
+ self.setWindowTitle("Pixel Math — Preset")
2117
+ init = dict(initial or {})
2118
+ v = QVBoxLayout(self)
2119
+ self.rb_single = QRadioButton("Single"); self.rb_single.setChecked(init.get("mode","single")=="single")
2120
+ self.rb_rgb = QRadioButton("Per-channel"); self.rb_rgb.setChecked(init.get("mode","single")=="rgb")
2121
+ row = QHBoxLayout(); row.addWidget(self.rb_single); row.addWidget(self.rb_rgb); row.addStretch(1)
2122
+ v.addLayout(row)
2123
+ self.ed_single = QPlainTextEdit(); self.ed_single.setPlaceholderText("expr"); self.ed_single.setPlainText(init.get("expr",""))
2124
+ v.addWidget(self.ed_single)
2125
+ self.tabs = QTabWidget();
2126
+ self.ed_r, self.ed_g, self.ed_b = QPlainTextEdit(), QPlainTextEdit(), QPlainTextEdit()
2127
+ for ed, name, key in ((self.ed_r,"Red","expr_r"),(self.ed_g,"Green","expr_g"),(self.ed_b,"Blue","expr_b")):
2128
+ w = QWidget(); lay = QVBoxLayout(w); ed.setPlainText(init.get(key,"")); lay.addWidget(ed); self.tabs.addTab(w, name)
2129
+ v.addWidget(self.tabs)
2130
+ self.rb_single.toggled.connect(lambda on: (self.ed_single.setVisible(on), self.tabs.setVisible(not on)))
2131
+ self.rb_single.toggled.emit(self.rb_single.isChecked())
2132
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2133
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2134
+ v.addWidget(btns)
2135
+ def result_dict(self) -> dict:
2136
+ if self.rb_single.isChecked():
2137
+ return {"mode":"single","expr":self.ed_single.toPlainText().strip()}
2138
+ return {"mode":"rgb","expr_r":self.ed_r.toPlainText().strip(),
2139
+ "expr_g":self.ed_g.toPlainText().strip(),"expr_b":self.ed_b.toPlainText().strip()}
2140
+
2141
+ class _SignatureInsertPresetDialog(QDialog):
2142
+ """
2143
+ Preset editor for Signature / Insert.
2144
+ Keeps the PNG path + placement so users can drag a shortcut and re-apply.
2145
+ """
2146
+ POS_KEYS = [
2147
+ ("Top-Left", "top_left"),
2148
+ ("Top-Center", "top_center"),
2149
+ ("Top-Right", "top_right"),
2150
+ ("Middle-Left", "middle_left"),
2151
+ ("Center", "center"),
2152
+ ("Middle-Right", "middle_right"),
2153
+ ("Bottom-Left", "bottom_left"),
2154
+ ("Bottom-Center", "bottom_center"),
2155
+ ("Bottom-Right", "bottom_right"),
2156
+ ]
2157
+
2158
+ def __init__(self, parent, initial: dict | None = None):
2159
+ super().__init__(parent)
2160
+ self.setWindowTitle("Signature / Insert – Preset")
2161
+ self.setMinimumWidth(520)
2162
+
2163
+ init = dict(initial or {})
2164
+ v = QVBoxLayout(self)
2165
+
2166
+ tip = QLabel("Tip: For transparent signatures, use a PNG and “Load from File”. "
2167
+ "Views are RGB, so alpha is not preserved.")
2168
+ tip.setWordWrap(True)
2169
+ tip.setStyleSheet("color:#e0b000;")
2170
+ v.addWidget(tip)
2171
+
2172
+ grid = QGridLayout()
2173
+
2174
+ # File path
2175
+ grid.addWidget(QLabel("Signature file (PNG/JPG/TIF):"), 0, 0)
2176
+ self.ed_path = QLineEdit(init.get("file_path", ""))
2177
+ b_browse = QPushButton("Browse…")
2178
+ def _pick():
2179
+ fp, _ = QFileDialog.getOpenFileName(self, "Select signature image",
2180
+ "", "Images (*.png *.jpg *.jpeg *.tif *.tiff)")
2181
+ if fp: self.ed_path.setText(fp)
2182
+ b_browse.clicked.connect(_pick)
2183
+ grid.addWidget(self.ed_path, 0, 1)
2184
+ grid.addWidget(b_browse, 0, 2)
2185
+
2186
+ # Position
2187
+ grid.addWidget(QLabel("Position:"), 1, 0)
2188
+ self.cb_pos = QComboBox()
2189
+ for text, key in self.POS_KEYS:
2190
+ self.cb_pos.addItem(text, userData=key)
2191
+ want = init.get("position", "bottom_right")
2192
+ idx = max(0, next((i for i,(_,k) in enumerate(self.POS_KEYS) if k == want), 0))
2193
+ self.cb_pos.setCurrentIndex(idx)
2194
+ grid.addWidget(self.cb_pos, 1, 1)
2195
+
2196
+ # Margins
2197
+ grid.addWidget(QLabel("Margin X (px):"), 2, 0)
2198
+ self.sp_mx = QSpinBox(); self.sp_mx.setRange(0, 5000); self.sp_mx.setValue(int(init.get("margin_x", 20)))
2199
+ grid.addWidget(self.sp_mx, 2, 1)
2200
+
2201
+ grid.addWidget(QLabel("Margin Y (px):"), 3, 0)
2202
+ self.sp_my = QSpinBox(); self.sp_my.setRange(0, 5000); self.sp_my.setValue(int(init.get("margin_y", 20)))
2203
+ grid.addWidget(self.sp_my, 3, 1)
2204
+
2205
+ # Scale / Opacity / Rotation
2206
+ grid.addWidget(QLabel("Scale (%)"), 4, 0)
2207
+ self.sp_scale = QSpinBox(); self.sp_scale.setRange(10, 800); self.sp_scale.setValue(int(init.get("scale", 100)))
2208
+ grid.addWidget(self.sp_scale, 4, 1)
2209
+
2210
+ grid.addWidget(QLabel("Opacity (%)"), 5, 0)
2211
+ self.sp_op = QSpinBox(); self.sp_op.setRange(0, 100); self.sp_op.setValue(int(init.get("opacity", 100)))
2212
+ grid.addWidget(self.sp_op, 5, 1)
2213
+
2214
+ grid.addWidget(QLabel("Rotation (°)"), 6, 0)
2215
+ self.sp_rot = QSpinBox(); self.sp_rot.setRange(-180, 180); self.sp_rot.setValue(int(init.get("rotation", 0)))
2216
+ grid.addWidget(self.sp_rot, 6, 1)
2217
+
2218
+ # Auto affix
2219
+ self.cb_affix = QCheckBox("Auto-affix after placement")
2220
+ self.cb_affix.setChecked(bool(init.get("auto_affix", True)))
2221
+ grid.addWidget(self.cb_affix, 7, 0, 1, 2)
2222
+
2223
+ v.addLayout(grid)
2224
+
2225
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
2226
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2227
+ v.addWidget(btns)
2228
+
2229
+ def result_dict(self) -> dict:
2230
+ return {
2231
+ "file_path": self.ed_path.text().strip(),
2232
+ "position": self.cb_pos.currentData(),
2233
+ "margin_x": int(self.sp_mx.value()),
2234
+ "margin_y": int(self.sp_my.value()),
2235
+ "scale": int(self.sp_scale.value()),
2236
+ "opacity": int(self.sp_op.value()),
2237
+ "rotation": int(self.sp_rot.value()),
2238
+ "auto_affix": bool(self.cb_affix.isChecked()),
2239
+ }
2240
+
2241
+ class _HaloBGonPresetDialog(QDialog):
2242
+ def __init__(self, parent=None, initial: dict | None = None):
2243
+ super().__init__(parent)
2244
+ self.setWindowTitle("Halo-B-Gon Preset")
2245
+ v = QVBoxLayout(self)
2246
+ g = QGridLayout(); v.addLayout(g)
2247
+
2248
+ g.addWidget(QLabel("Reduction:"), 0, 0)
2249
+ self.sl = QSlider(Qt.Orientation.Horizontal); self.sl.setRange(0,3); self.sl.setValue(int((initial or {}).get("reduction",0)))
2250
+ self.lab = QLabel(["Extra Low","Low","Medium","High"][self.sl.value()])
2251
+ self.sl.valueChanged.connect(lambda v: self.lab.setText(["Extra Low","Low","Medium","High"][int(v)]))
2252
+ g.addWidget(self.sl, 0, 1); g.addWidget(self.lab, 0, 2)
2253
+
2254
+ self.cb = QCheckBox("Linear data"); self.cb.setChecked(bool((initial or {}).get("linear",False)))
2255
+ g.addWidget(self.cb, 1, 1)
2256
+
2257
+ row = QHBoxLayout(); v.addLayout(row)
2258
+ ok = QPushButton("OK"); ok.clicked.connect(self.accept)
2259
+ ca = QPushButton("Cancel"); ca.clicked.connect(self.reject)
2260
+ row.addStretch(1); row.addWidget(ok); row.addWidget(ca)
2261
+
2262
+ def result_dict(self) -> dict:
2263
+ return {"reduction": int(self.sl.value()), "linear": bool(self.cb.isChecked())}
2264
+
2265
+ class _RescalePresetDialog(QDialog):
2266
+ """
2267
+ Preset dialog for Geometry → Rescale.
2268
+ Stores: {"factor": float} where factor ∈ [0.10, 10.00].
2269
+ """
2270
+ def __init__(self, parent=None, initial=None):
2271
+ super().__init__(parent)
2272
+ self.setWindowTitle("Rescale Preset")
2273
+ self._initial = initial or {}
2274
+
2275
+ from PyQt6.QtWidgets import QFormLayout, QDoubleSpinBox, QDialogButtonBox
2276
+
2277
+ lay = QVBoxLayout(self)
2278
+ form = QFormLayout()
2279
+
2280
+ self.spn_factor = QDoubleSpinBox(self)
2281
+ self.spn_factor.setDecimals(2)
2282
+ self.spn_factor.setRange(0.10, 10.00)
2283
+ self.spn_factor.setSingleStep(0.05)
2284
+ self.spn_factor.setValue(float(self._initial.get("factor", 1.0)))
2285
+ form.addRow("Scaling factor:", self.spn_factor)
2286
+
2287
+ lay.addLayout(form)
2288
+
2289
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2290
+ btns.accepted.connect(self.accept)
2291
+ btns.rejected.connect(self.reject)
2292
+ lay.addWidget(btns)
2293
+
2294
+ self.resize(320, 120)
2295
+
2296
+ def result_dict(self):
2297
+ return {"factor": float(self.spn_factor.value())}
2298
+
2299
+ class _ImageCombinePresetDialog(QDialog):
2300
+ def __init__(self, parent, initial: dict):
2301
+ super().__init__(parent); self.setWindowTitle("Image Combine Preset")
2302
+ mode = QComboBox(); mode.addItems(["Average","Add","Subtract","Blend","Multiply","Divide","Screen","Overlay","Difference"])
2303
+ mode.setCurrentText(initial.get("mode", "Blend"))
2304
+ alpha = QSlider(Qt.Orientation.Horizontal); alpha.setRange(0,100); alpha.setValue(int(100*float(initial.get("opacity",1.0))))
2305
+ luma = QCheckBox("Luminance only"); luma.setChecked(bool(initial.get("luma_only", False)))
2306
+ out_rep = QRadioButton("Replace A"); out_new = QRadioButton("Create new"); (out_new if initial.get("output")=="new" else out_rep).setChecked(True)
2307
+ from PyQt6.QtWidgets import QLineEdit
2308
+ other = QLineEdit(initial.get("docB_title","")); other.setPlaceholderText("Optional: exact title of B")
2309
+
2310
+ form = QFormLayout()
2311
+ form.addRow("Mode:", mode)
2312
+ form.addRow("Opacity:", alpha)
2313
+ form.addRow("", luma)
2314
+ form.addRow("Output:", None)
2315
+ h = QHBoxLayout(); h.addWidget(out_rep); h.addWidget(out_new); h.addStretch(1)
2316
+ form.addRow("", QLabel(""))
2317
+ root = QVBoxLayout(self); root.addLayout(form); root.addLayout(h)
2318
+ form.addRow("Other source (title):", other)
2319
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2320
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2321
+ root.addWidget(btns)
2322
+ self._mode, self._alpha, self._luma, self._rep, self._other = mode, alpha, luma, out_rep, other
2323
+
2324
+ def result_dict(self):
2325
+ return {
2326
+ "mode": self._mode.currentText(),
2327
+ "opacity": self._alpha.value()/100.0,
2328
+ "luma_only": self._luma.isChecked(),
2329
+ "output": "replace" if self._rep.isChecked() else "new",
2330
+ "docB_title": self._other.text().strip(),
2331
+ }
2332
+
2333
+ class _StarSpikesPresetDialog(QDialog):
2334
+ def __init__(self, parent=None, initial: dict | None = None):
2335
+ super().__init__(parent)
2336
+ self.setWindowTitle("Diffraction Spikes Preset")
2337
+ v = QVBoxLayout(self)
2338
+ g = QGridLayout(); v.addLayout(g)
2339
+ ini = dict(initial or {})
2340
+
2341
+ row = 0
2342
+ def dspin(mini, maxi, step, key, default):
2343
+ sp = QDoubleSpinBox(); sp.setRange(mini, maxi); sp.setSingleStep(step); sp.setValue(float(ini.get(key, default)))
2344
+ return sp
2345
+
2346
+ def ispin(mini, maxi, step, key, default):
2347
+ sp = QSpinBox(); sp.setRange(mini, maxi); sp.setSingleStep(step); sp.setValue(int(ini.get(key, default)))
2348
+ return sp
2349
+
2350
+ self.flux_min = dspin(0.0, 999999.0, 10.0, "flux_min", 30.0); g.addWidget(QLabel("Flux Min:"), row,0); g.addWidget(self.flux_min, row,1); row+=1
2351
+ self.flux_max = dspin(1.0, 999999.0, 50.0, "flux_max", 300.0); g.addWidget(QLabel("Flux Max:"), row,0); g.addWidget(self.flux_max, row,1); row+=1
2352
+ self.bmin = dspin(0.1, 999.0, 0.5, "bscale_min", 10.0); g.addWidget(QLabel("Boost Min:"), row,0); g.addWidget(self.bmin, row,1); row+=1
2353
+ self.bmax = dspin(0.1, 999.0, 0.5, "bscale_max", 30.0); g.addWidget(QLabel("Boost Max:"), row,0); g.addWidget(self.bmax, row,1); row+=1
2354
+ self.smin = dspin(0.1, 999.0, 0.1, "shrink_min", 1.0); g.addWidget(QLabel("Shrink Min:"), row,0); g.addWidget(self.smin, row,1); row+=1
2355
+ self.smax = dspin(0.1, 999.0, 0.1, "shrink_max", 5.0); g.addWidget(QLabel("Shrink Max:"), row,0); g.addWidget(self.smax, row,1); row+=1
2356
+ self.dth = dspin(0.0, 100.0, 0.1, "detect_thresh", 5.0);g.addWidget(QLabel("Detect Threshold:"), row,0); g.addWidget(self.dth, row,1); row+=1
2357
+ self.radius = dspin(1.0, 512.0, 1.0, "radius", 128.0); g.addWidget(QLabel("Pupil Radius:"), row,0); g.addWidget(self.radius, row,1); row+=1
2358
+ self.obstr = dspin(0.0, 0.99, 0.01, "obstruction", 0.2); g.addWidget(QLabel("Obstruction:"), row,0); g.addWidget(self.obstr, row,1); row+=1
2359
+ self.vanes = ispin(2, 8, 1, "num_vanes", 4); g.addWidget(QLabel("Num Vanes:"), row,0); g.addWidget(self.vanes, row,1); row+=1
2360
+ self.vwidth = dspin(0.0, 50.0, 0.5, "vane_width", 4.0); g.addWidget(QLabel("Vane Width:"), row,0); g.addWidget(self.vwidth, row,1); row+=1
2361
+ self.rotdeg = dspin(0.0, 360.0, 1.0, "rotation", 0.0); g.addWidget(QLabel("Rotation (°):"), row,0); g.addWidget(self.rotdeg, row,1); row+=1
2362
+ self.boost = dspin(0.1, 10.0, 0.1, "color_boost", 1.5); g.addWidget(QLabel("Spike Boost:"), row,0); g.addWidget(self.boost, row,1); row+=1
2363
+ self.blur = dspin(0.1, 10.0, 0.1, "blur_sigma", 2.0); g.addWidget(QLabel("PSF Blur Sigma:"), row,0); g.addWidget(self.blur, row,1); row+=1
2364
+
2365
+ self.jwst = QCheckBox("JWST Pupil"); self.jwst.setChecked(bool(ini.get("jwst", False)))
2366
+ g.addWidget(self.jwst, row, 0, 1, 2); row += 1
2367
+
2368
+ rowbox = QHBoxLayout(); v.addLayout(rowbox)
2369
+ ok = QPushButton("OK"); ca = QPushButton("Cancel")
2370
+ ok.clicked.connect(self.accept); ca.clicked.connect(self.reject)
2371
+ rowbox.addStretch(1); rowbox.addWidget(ok); rowbox.addWidget(ca)
2372
+
2373
+ def result_dict(self) -> dict:
2374
+ return {
2375
+ "flux_min": float(self.flux_min.value()),
2376
+ "flux_max": float(self.flux_max.value()),
2377
+ "bscale_min": float(self.bmin.value()),
2378
+ "bscale_max": float(self.bmax.value()),
2379
+ "shrink_min": float(self.smin.value()),
2380
+ "shrink_max": float(self.smax.value()),
2381
+ "detect_thresh": float(self.dth.value()),
2382
+ "radius": float(self.radius.value()),
2383
+ "obstruction": float(self.obstr.value()),
2384
+ "num_vanes": int(self.vanes.value()),
2385
+ "vane_width": float(self.vwidth.value()),
2386
+ "rotation": float(self.rotdeg.value()),
2387
+ "color_boost": float(self.boost.value()),
2388
+ "blur_sigma": float(self.blur.value()),
2389
+ "jwst": bool(self.jwst.isChecked()),
2390
+ }
2391
+
2392
+ class _DebayerPresetDialog(QDialog):
2393
+ def __init__(self, parent=None, initial: dict | None = None):
2394
+ super().__init__(parent)
2395
+ self.setWindowTitle("Debayer — Preset")
2396
+ init = dict(initial or {})
2397
+ self.combo = QComboBox(self)
2398
+ self.combo.addItems(["auto", "RGGB", "BGGR", "GRBG", "GBRG"])
2399
+ want = str(init.get("pattern", "auto")).upper()
2400
+ idx = max(0, self.combo.findText(want, Qt.MatchFlag.MatchFixedString))
2401
+ self.combo.setCurrentIndex(idx)
2402
+
2403
+ lay = QVBoxLayout(self)
2404
+ row = QHBoxLayout(); row.addWidget(QLabel("Bayer pattern:")); row.addWidget(self.combo, 1)
2405
+ lay.addLayout(row)
2406
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2407
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2408
+ lay.addWidget(btns)
2409
+
2410
+ def result_dict(self) -> dict:
2411
+ return {"pattern": self.combo.currentText().upper()}
2412
+
2413
+ from setiastro.saspro.curves_preset import list_custom_presets, _norm_mode
2414
+
2415
+ class _CurvesPresetDialog(QDialog):
2416
+ def __init__(self, parent=None, initial: dict | None = None):
2417
+ super().__init__(parent)
2418
+ self.setWindowTitle("Curves — Preset")
2419
+ init = dict(initial or {})
2420
+
2421
+ # --- Mode ---------------------------------------------------------
2422
+ self.mode = QComboBox()
2423
+ self.mode.addItems(["K (Brightness)", "R", "G", "B", "L*", "a*", "b*", "Chroma", "Saturation"])
2424
+ want = (init.get("mode") or "K (Brightness)").strip()
2425
+ self.mode.setCurrentIndex(max(0, self.mode.findText(want)))
2426
+
2427
+ # --- Shape --------------------------------------------------------
2428
+ self.shape = QComboBox()
2429
+ self.shape.addItem("Linear", "linear")
2430
+ self.shape.addItem("S-curve (mild)", "s_mild")
2431
+ self.shape.addItem("S-curve (medium)", "s_med")
2432
+ self.shape.addItem("S-curve (strong)", "s_strong")
2433
+ self.shape.addItem("Lift shadows", "lift_shadows")
2434
+ self.shape.addItem("Crush shadows", "crush_shadows")
2435
+ self.shape.addItem("Fade blacks", "fade_blacks")
2436
+ self.shape.addItem("Highlight roll-off", "rolloff_highlights")
2437
+ self.shape.addItem("Flatten contrast", "flatten")
2438
+ self.shape.addItem("Custom points", "custom")
2439
+ self.shape.setCurrentIndex(max(0, self.shape.findData((init.get("shape") or "linear").lower())))
2440
+
2441
+ # --- Amount (ignored if custom) -----------------------------------
2442
+ self.amount = QDoubleSpinBox()
2443
+ self.amount.setRange(0.0, 1.0); self.amount.setDecimals(2)
2444
+ self.amount.setSingleStep(0.05)
2445
+ self.amount.setValue(float(init.get("amount", 0.50)))
2446
+
2447
+ # --- Custom points (normalized "x,y; x,y; ...") -------------------
2448
+ self.points = QLineEdit()
2449
+ self.points.setPlaceholderText("points_norm: x,y; x,y; ... (0..1) e.g. 0,0; 0.25,0.15; 0.75,0.85; 1,1")
2450
+ if isinstance(init.get("points_norm"), (list, tuple)) and init["points_norm"]:
2451
+ s = "; ".join(f"{float(x):.6g},{float(y):.6g}" for x, y in init["points_norm"])
2452
+ self.points.setText(s)
2453
+
2454
+ # ===================== Custom Presets picker ======================
2455
+ self.preset_picker = QComboBox()
2456
+ self.btn_load = QPushButton("Load custom → fields")
2457
+
2458
+ # populate & enable/disable based on availability
2459
+ self._rebuild_customs()
2460
+ self.btn_load.clicked.connect(self._load_selected_preset_into_fields)
2461
+
2462
+ # wrap the load-row in a QWidget so we can hide/show the whole row
2463
+ load_row = QHBoxLayout()
2464
+ load_row.setContentsMargins(0, 0, 0, 0)
2465
+ load_row.addWidget(self.btn_load)
2466
+ self._row_custom_controls = QWidget(self)
2467
+ self._row_custom_controls.setLayout(load_row)
2468
+
2469
+ # layout (use explicit labels so they can be hidden with the row)
2470
+ form = QFormLayout(self)
2471
+ form.addRow(QLabel("Mode:", self), self.mode)
2472
+ form.addRow(QLabel("Shape:", self), self.shape)
2473
+ form.addRow(QLabel("Amount (0–1):", self), self.amount)
2474
+ form.addRow(QLabel("Custom points:", self), self.points)
2475
+
2476
+ self._lbl_custom_picker = QLabel("Custom presets:", self)
2477
+ form.addRow(self._lbl_custom_picker, self.preset_picker)
2478
+ form.addRow(QLabel("", self), self._row_custom_controls)
2479
+
2480
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2481
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2482
+ form.addRow(btns)
2483
+
2484
+ # enable/disable + show/hide depending on shape
2485
+ def _update_enabled():
2486
+ custom = (self.shape.currentData() == "custom")
2487
+ self.points.setEnabled(custom)
2488
+ self.amount.setEnabled(custom)
2489
+
2490
+ # show/hide the custom presets UI as requested
2491
+ self._set_custom_picker_visible(custom)
2492
+
2493
+ self.shape.currentIndexChanged.connect(_update_enabled)
2494
+ _update_enabled()
2495
+ # ---------------------------------------------------------------------
2496
+
2497
+ def _set_custom_picker_visible(self, visible: bool):
2498
+ """Show/hide the custom presets picker + load row."""
2499
+ for w in (self._lbl_custom_picker, self.preset_picker, self._row_custom_controls):
2500
+ w.setVisible(bool(visible))
2501
+
2502
+ def _rebuild_customs(self):
2503
+ """Refresh the list from QSettings and (de)activate picker/load."""
2504
+ self.preset_picker.clear()
2505
+ customs = list_custom_presets()
2506
+ if not customs:
2507
+ self.preset_picker.addItem("(No custom presets saved)", userData=None)
2508
+ self.preset_picker.setEnabled(False)
2509
+ self.btn_load.setEnabled(False)
2510
+ return
2511
+ self.preset_picker.setEnabled(True)
2512
+ self.btn_load.setEnabled(True)
2513
+ for p in sorted(customs, key=lambda d: d.get("name", "").lower()):
2514
+ self.preset_picker.addItem(p.get("name", "(unnamed)"), userData=p)
2515
+
2516
+ def _load_selected_preset_into_fields(self):
2517
+ p = self.preset_picker.currentData()
2518
+ if not isinstance(p, dict):
2519
+ return
2520
+ # mode
2521
+ want = _norm_mode(p.get("mode"))
2522
+ idx = self.mode.findText(want)
2523
+ if idx >= 0:
2524
+ self.mode.setCurrentIndex(idx)
2525
+ # switch to custom
2526
+ j = self.shape.findData("custom")
2527
+ if j >= 0:
2528
+ self.shape.setCurrentIndex(j)
2529
+ # points → text
2530
+ pts = p.get("points_norm") or []
2531
+ if isinstance(pts, (list, tuple)) and pts:
2532
+ s = "; ".join(f"{float(x):.6g},{float(y):.6g}" for x, y in pts)
2533
+ self.points.setText(s)
2534
+
2535
+ # -------------------- parsing & result -------------------------------
2536
+ def _parse_points_text(self) -> list[tuple[float, float]]:
2537
+ txt = (self.points.text() or "").strip()
2538
+ if not txt:
2539
+ return []
2540
+ s = txt.replace("\n", ";").replace("\r", ";")
2541
+ parts = [p.strip() for p in s.split(";") if p.strip()]
2542
+ out: list[tuple[float, float]] = []
2543
+ for part in parts:
2544
+ p = part.replace(",", " ").split()
2545
+ if len(p) != 2:
2546
+ continue
2547
+ try:
2548
+ x = float(p[0]); y = float(p[1])
2549
+ except ValueError:
2550
+ continue
2551
+ out.append((max(0.0, min(1.0, x)), max(0.0, min(1.0, y))))
2552
+
2553
+ if out:
2554
+ if all(abs(x - 0.0) > 1e-6 for x, _ in out): out.insert(0, (0.0, 0.0))
2555
+ if all(abs(x - 1.0) > 1e-6 for x, _ in out): out.append((1.0, 1.0))
2556
+ out = sorted(out, key=lambda t: t[0])
2557
+ cleaned, lastx = [], -1.0
2558
+ for x, y in out:
2559
+ if x <= lastx: x = min(1.0, lastx + 1e-4)
2560
+ cleaned.append((x, y)); lastx = x
2561
+ out = cleaned
2562
+ return out
2563
+
2564
+ def result_dict(self) -> dict:
2565
+ mode = _norm_mode(self.mode.currentText())
2566
+ shape = self.shape.currentData() or "linear"
2567
+ amt = float(self.amount.value())
2568
+ d = {"mode": mode, "shape": shape, "amount": amt}
2569
+ if shape == "custom":
2570
+ pts = self._parse_points_text()
2571
+ if pts:
2572
+ d["points_norm"] = pts
2573
+ else:
2574
+ d["shape"] = "linear"
2575
+ d.pop("points_norm", None)
2576
+ return d
2577
+
2578
+ class _GHSPresetDialog(QDialog):
2579
+ def __init__(self, parent=None, initial: dict | None = None):
2580
+ super().__init__(parent)
2581
+ self.setWindowTitle("Universal Hyperbolic Stretch — Preset")
2582
+ init = dict(initial or {})
2583
+
2584
+ self.mode = QComboBox()
2585
+ self.mode.addItems(["K (Brightness)", "R", "G", "B"])
2586
+ want = (init.get("channel") or "K (Brightness)").strip()
2587
+ i = self.mode.findText(want); self.mode.setCurrentIndex(max(0, i))
2588
+
2589
+ def _mk_spin(minv, maxv, step, val, dec=2):
2590
+ s = QDoubleSpinBox(); s.setRange(minv, maxv); s.setDecimals(dec); s.setSingleStep(step); s.setValue(val); return s
2591
+
2592
+ self.alpha = _mk_spin(0.02, 10.0, 0.02, float(init.get("alpha", 1.00)))
2593
+ self.beta = _mk_spin(0.02, 10.0, 0.02, float(init.get("beta", 1.00)))
2594
+ self.gamma = _mk_spin(0.01, 5.0, 0.01, float(init.get("gamma", 1.00)))
2595
+ self.pivot = _mk_spin(0.00, 1.0, 0.01, float(init.get("pivot", 0.50)))
2596
+ self.lp = _mk_spin(0.00, 1.0, 0.01, float(init.get("lp", 0.00)))
2597
+ self.hp = _mk_spin(0.00, 1.0, 0.01, float(init.get("hp", 0.00)))
2598
+
2599
+ form = QFormLayout(self)
2600
+ form.addRow("Channel:", self.mode)
2601
+ form.addRow("α (0.02–10):", self.alpha)
2602
+ form.addRow("β (0.02–10):", self.beta)
2603
+ form.addRow("γ (0.01–5):", self.gamma)
2604
+ form.addRow("Pivot (0–1):", self.pivot)
2605
+ form.addRow("LP (0–1):", self.lp)
2606
+ form.addRow("HP (0–1):", self.hp)
2607
+
2608
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2609
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2610
+ form.addRow(btns)
2611
+
2612
+ def result_dict(self) -> dict:
2613
+ return {
2614
+ "channel": self.mode.currentText(),
2615
+ "alpha": float(self.alpha.value()),
2616
+ "beta": float(self.beta.value()),
2617
+ "gamma": float(self.gamma.value()),
2618
+ "pivot": float(self.pivot.value()),
2619
+ "lp": float(self.lp.value()),
2620
+ "hp": float(self.hp.value()),
2621
+ }
2622
+
2623
+ class _ABEPresetDialog(QDialog):
2624
+ def __init__(self, parent=None, initial: dict | None = None):
2625
+ super().__init__(parent)
2626
+ self.setWindowTitle("ABE — Preset")
2627
+ p = dict(initial or {})
2628
+ form = QFormLayout(self)
2629
+
2630
+ self.degree = QSpinBox(); self.degree.setRange(1, 6); self.degree.setValue(int(p.get("degree", 2)))
2631
+ self.samples = QSpinBox(); self.samples.setRange(20, 100000); self.samples.setSingleStep(20); self.samples.setValue(int(p.get("samples", 120)))
2632
+ self.down = QSpinBox(); self.down.setRange(1, 64); self.down.setValue(int(p.get("downsample", 6)))
2633
+ self.patch = QSpinBox(); self.patch.setRange(5, 151); self.patch.setSingleStep(2); self.patch.setValue(int(p.get("patch", 15)))
2634
+ self.rbf = QCheckBox("Enable RBF"); self.rbf.setChecked(bool(p.get("rbf", True)))
2635
+ self.smooth = QDoubleSpinBox(); self.smooth.setRange(0.0, 10.0); self.smooth.setDecimals(3); self.smooth.setSingleStep(0.01); self.smooth.setValue(float(p.get("rbf_smooth", 1.0)))
2636
+ self.mk_bg = QCheckBox("Also create background document"); self.mk_bg.setChecked(bool(p.get("make_background_doc", False)))
2637
+
2638
+ form.addRow("Polynomial degree:", self.degree)
2639
+ form.addRow("# samples:", self.samples)
2640
+ form.addRow("Downsample:", self.down)
2641
+ form.addRow("Patch size (px):", self.patch)
2642
+ form.addRow(self.rbf)
2643
+ form.addRow("RBF smooth:", self.smooth)
2644
+ form.addRow(self.mk_bg)
2645
+
2646
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2647
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2648
+ form.addRow(btns)
2649
+
2650
+ def result_dict(self) -> dict:
2651
+ return {
2652
+ "degree": int(self.degree.value()),
2653
+ "samples": int(self.samples.value()),
2654
+ "downsample": int(self.down.value()),
2655
+ "patch": int(self.patch.value()),
2656
+ "rbf": bool(self.rbf.isChecked()),
2657
+ "rbf_smooth": float(self.smooth.value()),
2658
+ "make_background_doc": bool(self.mk_bg.isChecked()),
2659
+ # exclusion polygons: intentionally unsupported here
2660
+ }
2661
+ class _CropPresetDialog(QDialog):
2662
+ def __init__(self, parent=None, initial: dict | None = None):
2663
+ super().__init__(parent)
2664
+ self.setWindowTitle("Crop Preset")
2665
+ init = dict(initial or {})
2666
+ mode = str(init.get("mode", "margins")).lower()
2667
+ margins = dict(init.get("margins", {}))
2668
+
2669
+ lay = QVBoxLayout(self)
2670
+ form = QFormLayout()
2671
+
2672
+ # --- Mode + help button row --------------------------------------
2673
+ self.cmb_mode = QComboBox()
2674
+ self.cmb_mode.addItems(["margins", "rect_norm", "quad_norm"])
2675
+ self.cmb_mode.setCurrentText(mode)
2676
+ # Per-item tooltips
2677
+ self.cmb_mode.setItemData(0, "Crop by pixel margins from each edge.", Qt.ItemDataRole.ToolTipRole)
2678
+ self.cmb_mode.setItemData(1, "Axis-aligned rectangle in 0..1 normalized coords (optional rotation).", Qt.ItemDataRole.ToolTipRole)
2679
+ self.cmb_mode.setItemData(2, "Four corners (TL,TR,BR,BL) in 0..1 normalized coords for perspective/keystone.", Qt.ItemDataRole.ToolTipRole)
2680
+
2681
+ # Tiny "?" button
2682
+ self.btn_mode_help = QToolButton()
2683
+ self.btn_mode_help.setText("?")
2684
+ self.btn_mode_help.setToolTip("What do these modes mean?")
2685
+ self.btn_mode_help.setFixedWidth(24)
2686
+ self.btn_mode_help.clicked.connect(self._show_mode_help)
2687
+
2688
+ # Put combo + help button on one row for the form
2689
+ mode_row = QWidget(self)
2690
+ mode_row_lay = QHBoxLayout(mode_row)
2691
+ mode_row_lay.setContentsMargins(0, 0, 0, 0)
2692
+ mode_row_lay.addWidget(self.cmb_mode, 1)
2693
+ mode_row_lay.addWidget(self.btn_mode_help, 0)
2694
+ form.addRow("Mode:", mode_row)
2695
+ # -----------------------------------------------------------------
2696
+
2697
+ # Margins UI
2698
+ self.top = QSpinBox(); self.right = QSpinBox(); self.bottom = QSpinBox(); self.left = QSpinBox()
2699
+ for sb in (self.top, self.right, self.bottom, self.left):
2700
+ sb.setRange(0, 1_000_000)
2701
+ self.top.setValue(int(margins.get("top", 0)))
2702
+ self.right.setValue(int(margins.get("right", 0)))
2703
+ self.bottom.setValue(int(margins.get("bottom", 0)))
2704
+ self.left.setValue(int(margins.get("left", 0)))
2705
+
2706
+ self.cb_new = QCheckBox("Create new view")
2707
+ self.cb_new.setChecked(bool(init.get("create_new_view", False)))
2708
+ self.le_title = QLineEdit(init.get("title", "Crop"))
2709
+
2710
+ form.addRow("Top (px):", self.top)
2711
+ form.addRow("Right (px):", self.right)
2712
+ form.addRow("Bottom (px):", self.bottom)
2713
+ form.addRow("Left (px):", self.left)
2714
+ form.addRow("", self.cb_new)
2715
+ form.addRow("New view title:", self.le_title)
2716
+ lay.addLayout(form)
2717
+
2718
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2719
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2720
+ lay.addWidget(btns)
2721
+
2722
+ def _show_mode_help(self):
2723
+ current = self.cmb_mode.currentText()
2724
+ txt = (
2725
+ "<b>Crop modes</b><br><br>"
2726
+ "<b>margins</b> — Crop by pixel offsets from each image edge.<br>"
2727
+ "• <i>top/right/bottom/left</i> are in pixels.<br><br>"
2728
+ "<b>rect_norm</b> — Axis-aligned rectangle (optionally rotated) expressed in normalized 0..1 units.<br>"
2729
+ "• Schema: { mode:'rect_norm', rect:{ x, y, w, h, angle_deg } }<br>"
2730
+ "• x,y: top-left; w,h: size; angle_deg: CCW rotation around center (optional).<br><br>"
2731
+ "<b>quad_norm</b> — Arbitrary 4-corner crop in normalized 0..1 units (perspective/keystone).<br>"
2732
+ "• Schema: { mode:'quad_norm', quad:[[xTL,yTL],[xTR,yTR],[xBR,yBR],[xBL,yBL]] }<br>"
2733
+ "• Order: TL, TR, BR, BL. (0,0)=top-left, (1,1)=bottom-right."
2734
+ )
2735
+ # Small extra hint for the selected item
2736
+ if current == "rect_norm":
2737
+ txt += "<br><br><i>Tip:</i> Use rect_norm for regular boxes; add a small angle when needed."
2738
+ elif current == "quad_norm":
2739
+ txt += "<br><br><i>Tip:</i> Use quad_norm when the box edges aren’t parallel (keystone or tilt)."
2740
+
2741
+ QMessageBox.information(self, "Crop modes help", txt)
2742
+
2743
+ def result_dict(self) -> dict:
2744
+ return {
2745
+ "mode": self.cmb_mode.currentText(),
2746
+ "margins": {
2747
+ "top": int(self.top.value()),
2748
+ "right": int(self.right.value()),
2749
+ "bottom": int(self.bottom.value()),
2750
+ "left": int(self.left.value()),
2751
+ },
2752
+ "create_new_view": bool(self.cb_new.isChecked()),
2753
+ "title": self.le_title.text().strip() or "Crop",
2754
+ }
2755
+
2756
+ class _RGBAlignPresetDialog(QDialog):
2757
+ def __init__(self, parent=None, initial: dict | None = None):
2758
+ super().__init__(parent)
2759
+ self.setWindowTitle("RGB Align — Preset")
2760
+ init = dict(initial or {})
2761
+ v = QVBoxLayout(self)
2762
+
2763
+ # ── model row ───────────────────────────────────────
2764
+ row = QHBoxLayout()
2765
+ row.addWidget(QLabel("Alignment model:"))
2766
+ self.cb_model = QComboBox()
2767
+ # include EDGE first
2768
+ self.cb_model.addItems(["edge", "homography", "affine", "poly3", "poly4"])
2769
+ want = init.get("model", "edge").lower()
2770
+ idx = max(0, self.cb_model.findText(want, Qt.MatchFlag.MatchFixedString))
2771
+ self.cb_model.setCurrentIndex(idx)
2772
+ row.addWidget(self.cb_model, 1)
2773
+ v.addLayout(row)
2774
+
2775
+ # ── SEP sigma ───────────────────────────────────────
2776
+ sep_row = QHBoxLayout()
2777
+ sep_row.addWidget(QLabel("SEP sigma:"))
2778
+ self.sb_sigma = QSpinBox()
2779
+ self.sb_sigma.setRange(1, 10)
2780
+ self.sb_sigma.setValue(int(init.get("sep_sigma", 3)))
2781
+ self.sb_sigma.setToolTip("Detection threshold (σ) for EDGE mode.\n"
2782
+ "Higher = fewer stars. Only used when model = EDGE.")
2783
+ sep_row.addWidget(self.sb_sigma)
2784
+ v.addLayout(sep_row)
2785
+
2786
+ # ── create new ──────────────────────────────────────
2787
+ self.chk_new = QCheckBox("Create new document")
2788
+ self.chk_new.setChecked(bool(init.get("new_doc", True)))
2789
+ v.addWidget(self.chk_new)
2790
+
2791
+ # ── buttons ─────────────────────────────────────────
2792
+ btns = QDialogButtonBox(
2793
+ QDialogButtonBox.StandardButton.Ok |
2794
+ QDialogButtonBox.StandardButton.Cancel,
2795
+ parent=self
2796
+ )
2797
+ btns.accepted.connect(self.accept)
2798
+ btns.rejected.connect(self.reject)
2799
+ v.addWidget(btns)
2800
+
2801
+ def result_dict(self) -> dict:
2802
+ return {
2803
+ "model": self.cb_model.currentText().lower(), # "edge" / "homography" / ...
2804
+ "sep_sigma": int(self.sb_sigma.value()), # <-- new
2805
+ "new_doc": bool(self.chk_new.isChecked()),
2806
+ }
2807
+