setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -209,6 +209,17 @@ class DraggableToolBar(QToolBar):
209
209
  s.setValue(self._settings_key, ids)
210
210
 
211
211
 
212
+ def _is_locked(self) -> bool:
213
+ """Check if toolbar icon movement is locked globally."""
214
+ s = self._settings()
215
+ # Default to False (unlocked)
216
+ return s.value("UI/ToolbarLocked", False, type=bool)
217
+
218
+ def _set_locked(self, locked: bool):
219
+ """Set the global lock state."""
220
+ s = self._settings()
221
+ s.setValue("UI/ToolbarLocked", locked)
222
+
212
223
  # install/remove our event filter when actions are added/removed
213
224
  def actionEvent(self, e):
214
225
  super().actionEvent(e)
@@ -259,6 +270,7 @@ class DraggableToolBar(QToolBar):
259
270
  if delta.manhattanLength() > QApplication.startDragDistance():
260
271
  mods_now = QApplication.keyboardModifiers()
261
272
  had_mod = self._press_had_mod.get(obj, False)
273
+
262
274
  # CASE 1: had/has modifiers → create desktop shortcut / function-bundle drag (existing behavior)
263
275
  if had_mod or self._mods_ok(mods_now):
264
276
  act = self._find_action_for_button(obj)
@@ -270,6 +282,20 @@ class DraggableToolBar(QToolBar):
270
282
  return True # consume
271
283
  else:
272
284
  # CASE 2: plain drag (no modifiers) → reorder within this toolbar
285
+ # CHECK LOCK STATE FIRST
286
+ if self._is_locked():
287
+ # Lock is active: DO NOT start drag.
288
+ # Should we consume the event?
289
+ # If we consume it, the button won't feel "pressed" anymore if the user keeps dragging?
290
+ # Actually, if we just return False, standard QToolButton behavior applies (it might think it's being pressed).
291
+ # However, we want to prevent the *reorder* logic.
292
+ # So simply doing nothing here is enough to prevent the reorder drag from starting.
293
+
294
+ # But we might want to let the user know, or just silently fail distinctively?
295
+ # Silently failing distinctively is what the user asked for (prevent involuntary move).
296
+ # If we return False, the button keeps tracking the mouse, which is fine (it won't click unless released inside).
297
+ return False
298
+
273
299
  self._start_reorder_drag_for_button(obj)
274
300
  self._suppress_release.add(obj)
275
301
  self._press_pos.pop(obj, None)
@@ -461,50 +487,73 @@ class DraggableToolBar(QToolBar):
461
487
  def _show_toolbutton_context_menu(self, btn: QToolButton, act: QAction, gpos: QPoint):
462
488
  m = QMenu(btn)
463
489
 
464
- m.addAction("Create Desktop Shortcut", lambda: self._add_shortcut_for_action(act))
490
+ m.addAction(self.tr("Create Desktop Shortcut"), lambda: self._add_shortcut_for_action(act))
465
491
 
466
492
  # Hide this icon
467
493
  cid = self._action_id(act)
468
494
  if cid:
469
495
  m.addSeparator()
470
- m.addAction("Hide this icon", lambda: self._set_action_hidden(act, True))
496
+ m.addAction(self.tr("Hide this icon"), lambda: self.window()._hide_action_to_hidden_toolbar(act))
497
+
471
498
 
472
499
  # (Optional) teach users about Alt+Drag:
473
500
  m.addSeparator()
474
- tip = m.addAction("Tip: Alt+Drag to create")
501
+ tip = m.addAction(self.tr("Tip: Alt+Drag to create"))
475
502
  tip.setEnabled(False)
476
503
 
477
504
  m.exec(gpos)
478
505
 
506
+
479
507
  def contextMenuEvent(self, ev):
480
508
  # Right-click on empty toolbar area
481
509
  m = QMenu(self)
482
510
 
511
+ # 1. Lock/Unlock Action
512
+ is_locked = self._is_locked()
513
+ act_lock = m.addAction(self.tr("Lock Toolbar Icons"))
514
+ act_lock.setCheckable(True)
515
+ act_lock.setChecked(is_locked)
516
+
517
+ def _toggle_lock(checked):
518
+ self._set_locked(checked)
519
+
520
+ act_lock.triggered.connect(_toggle_lock)
521
+
522
+ m.addSeparator()
523
+
483
524
  # Submenu listing hidden actions for this toolbar
484
- hidden = self._load_hidden_set()
485
- sub = m.addMenu("Show hidden…")
525
+ sub = m.addMenu(self.tr("Show hidden…"))
486
526
 
487
- # Build list from actions that are currently invisible
527
+ mw = self.window()
528
+ tb_hidden = getattr(mw, "_hidden_toolbar", lambda: None)()
488
529
  any_hidden = False
489
- for act in self.actions():
490
- cid = self._action_id(act)
491
- if cid and (cid in hidden) and (not act.isVisible()):
530
+ if tb_hidden:
531
+ for act in tb_hidden.actions():
532
+ # Skip separators
533
+ if act.isSeparator():
534
+ continue
492
535
  any_hidden = True
493
- sub.addAction(act.text() or cid, lambda a=act: self._set_action_hidden(a, False))
536
+ sub.addAction(act.text() or (act.property("command_id") or act.objectName() or "item"),
537
+ lambda a=act: mw._unhide_action_from_hidden_toolbar(a))
494
538
 
495
539
  if not any_hidden:
496
540
  sub.setEnabled(False)
497
541
 
498
542
  m.addSeparator()
499
- m.addAction("Reset hidden icons", self._reset_hidden_icons)
543
+ m.addAction(self.tr("Reset hidden icons"), self._reset_hidden_icons)
500
544
 
501
545
  m.exec(ev.globalPos())
502
546
 
503
547
  def _reset_hidden_icons(self):
504
- # Show everything and clear hidden list
505
- for act in self.actions():
506
- act.setVisible(True)
507
- self._save_hidden_set(set())
548
+ mw = self.window()
549
+ tb_hidden = getattr(mw, "_hidden_toolbar", lambda: None)()
550
+ if not tb_hidden:
551
+ return
552
+ # copy list because we'll mutate
553
+ acts = [a for a in tb_hidden.actions() if not a.isSeparator()]
554
+ for a in acts:
555
+ mw._unhide_action_from_hidden_toolbar(a)
556
+
508
557
 
509
558
 
510
559
  _PRESET_UI_IDS = {
@@ -515,7 +564,7 @@ _PRESET_UI_IDS = {
515
564
  "remove_green","star_align","background_neutral","white_balance","clahe",
516
565
  "morphology","pixel_math","rgb_align","signature_insert","signature_adder",
517
566
  "signature","halo_b_gon","geom_rescale","rescale","debayer","image_combine",
518
- "star_spikes","diffraction_spikes", "multiscale_decomp",
567
+ "star_spikes","diffraction_spikes", "multiscale_decomp","geom_rotate_any",
519
568
  }
520
569
 
521
570
  def _has_preset_editor_for_command(command_id: str) -> bool:
@@ -548,6 +597,12 @@ def _open_preset_editor_for_command(parent, command_id: str, initial: dict | Non
548
597
  })
549
598
  return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
550
599
 
600
+ if command_id == "geom_rotate_any":
601
+ dlg = _GeomRotateAnyPresetDialog(parent, initial=cur or {
602
+ "angle_deg": 0.0,
603
+ })
604
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
605
+
551
606
  if command_id == "curves":
552
607
  dlg = _CurvesPresetDialog(parent, initial=cur or {"shape":"linear","amount":0.5,"mode":"K (Brightness)"})
553
608
  return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
@@ -741,18 +796,18 @@ class ShortcutButton(QToolButton):
741
796
  # --- Context menu (run / preset / delete) ----------------------------
742
797
  def _context_menu(self, pos):
743
798
  m = QMenu(self)
744
- m.addAction("Run", lambda: self._mgr.trigger(self.command_id))
799
+ m.addAction(self.tr("Run"), lambda: self._mgr.trigger(self.command_id))
745
800
  m.addSeparator()
746
- m.addAction("Edit Preset…", self._edit_preset_ui)
747
- m.addAction("Clear Preset", lambda: self._save_preset(None))
748
- m.addAction("Rename…", self._rename) # ← NEW
801
+ m.addAction(self.tr("Edit Preset…"), self._edit_preset_ui)
802
+ m.addAction(self.tr("Clear Preset"), lambda: self._save_preset(None))
803
+ m.addAction(self.tr("Rename…"), self._rename) # ← NEW
749
804
  m.addSeparator()
750
- m.addAction("Delete", self._delete)
805
+ m.addAction(self.tr("Delete"), self._delete)
751
806
  m.exec(self.mapToGlobal(pos))
752
807
 
753
808
  def _rename(self):
754
809
  current = self.text()
755
- new_name, ok = QInputDialog.getText(self, "Rename Shortcut", "Name:", text=current)
810
+ new_name, ok = QInputDialog.getText(self, self.tr("Rename Shortcut"), self.tr("Name:"), text=current)
756
811
  if not ok or not new_name.strip():
757
812
  return
758
813
  self.setText(new_name.strip())
@@ -764,21 +819,21 @@ class ShortcutButton(QToolButton):
764
819
  result = _open_preset_editor_for_command(self, cid, cur)
765
820
  if result is not None:
766
821
  self._save_preset(result)
767
- QMessageBox.information(self, "Preset saved", "Preset stored on shortcut.")
822
+ QMessageBox.information(self, self.tr("Preset saved"), self.tr("Preset stored on shortcut."))
768
823
  return
769
824
 
770
825
  # Fallback: JSON editor
771
826
  raw = json.dumps(cur or {}, indent=2)
772
- text, ok = QInputDialog.getMultiLineText(self, "Edit Preset (JSON)", "Preset:", raw)
827
+ text, ok = QInputDialog.getMultiLineText(self, self.tr("Edit Preset (JSON)"), self.tr("Preset:"), raw)
773
828
  if ok:
774
829
  try:
775
830
  preset = json.loads(text or "{}")
776
831
  if not isinstance(preset, dict):
777
- raise ValueError("Preset must be a JSON object")
832
+ raise ValueError(self.tr("Preset must be a JSON object"))
778
833
  self._save_preset(preset)
779
- QMessageBox.information(self, "Preset saved", "Preset stored on shortcut.")
834
+ QMessageBox.information(self, self.tr("Preset saved"), self.tr("Preset stored on shortcut."))
780
835
  except Exception as e:
781
- QMessageBox.warning(self, "Invalid JSON", str(e))
836
+ QMessageBox.warning(self, self.tr("Invalid JSON"), str(e))
782
837
 
783
838
 
784
839
  def _start_command_drag(self):
@@ -978,17 +1033,14 @@ class ShortcutCanvas(QWidget):
978
1033
  return False
979
1034
  sw = self._top_subwindow_at(e.position().toPoint())
980
1035
  if sw is None:
981
- print("[ShortcutCanvas] _forward_command_drop: no subwindow under cursor", flush=True)
982
- QApplication.processEvents()
1036
+
983
1037
  return False
984
1038
  try:
985
1039
  raw = bytes(md.data(MIME_CMD))
986
1040
  payload = _unpack_cmd_payload(raw) # your existing helper
987
- print(f"[ShortcutCanvas] _forward_command_drop → subwin={sw}, payload={payload!r}", flush=True)
988
- QApplication.processEvents()
1041
+
989
1042
  except Exception as ex:
990
- print(f"[ShortcutCanvas] _forward_command_drop: failed to unpack payload: {ex!r}", flush=True)
991
- QApplication.processEvents()
1043
+
992
1044
  return False
993
1045
  self._mgr.apply_command_to_subwindow(sw, payload)
994
1046
  e.acceptProposedAction()
@@ -1066,11 +1118,11 @@ class ShortcutCanvas(QWidget):
1066
1118
  def contextMenuEvent(self, e):
1067
1119
  menu = QMenu(self)
1068
1120
  has_sel = bool(self._mgr.selected)
1069
- a_del = menu.addAction("Delete Selected", self._mgr.delete_selected); a_del.setEnabled(has_sel)
1070
- a_clr = menu.addAction("Clear Selection", self._mgr.clear_selection); a_clr.setEnabled(has_sel)
1121
+ a_del = menu.addAction(self.tr("Delete Selected"), self._mgr.delete_selected); a_del.setEnabled(has_sel)
1122
+ a_clr = menu.addAction(self.tr("Clear Selection"), self._mgr.clear_selection); a_clr.setEnabled(has_sel)
1071
1123
  menu.addSeparator()
1072
- a_vb = menu.addAction("View Bundles…", lambda: _open_view_bundles_from_canvas(self))
1073
- a_fb = menu.addAction("Function Bundles…", lambda: _open_function_bundles_from_canvas(self))
1124
+ a_vb = menu.addAction(self.tr("View Bundles…"), lambda: _open_view_bundles_from_canvas(self))
1125
+ a_fb = menu.addAction(self.tr("Function Bundles…"), lambda: _open_function_bundles_from_canvas(self))
1074
1126
  menu.exec(e.globalPos())
1075
1127
 
1076
1128
 
@@ -1490,6 +1542,7 @@ class ShortcutManager:
1490
1542
 
1491
1543
  # ---- persistence (QSettings JSON blob) ----
1492
1544
  def save_shortcuts(self):
1545
+
1493
1546
  data = []
1494
1547
  for sid, w in list(self.widgets.items()):
1495
1548
  if _is_dead(w):
@@ -1790,6 +1843,7 @@ class ShortcutManager:
1790
1843
 
1791
1844
 
1792
1845
  def clear(self):
1846
+
1793
1847
  for sid, w in list(self.widgets.items()):
1794
1848
  try:
1795
1849
  if not _is_dead(w):
@@ -1864,50 +1918,173 @@ class ShortcutManager:
1864
1918
  # legacy single-remove (kept for callers)
1865
1919
  self.delete_by_id(sid, persist=True)
1866
1920
 
1867
-
1868
1921
  class _StatStretchPresetDialog(QDialog):
1922
+ """
1923
+ Preset editor for headless replay: command_id="stat_stretch"
1924
+
1925
+ Keys supported:
1926
+ target_median: float
1927
+ linked: bool
1928
+ normalize: bool
1929
+ apply_curves: bool
1930
+ curves_boost: float # 0..1
1931
+ blackpoint_sigma: float # 0..1 (matches your slider/100)
1932
+ no_black_clip: bool
1933
+ hdr_compress: bool
1934
+ hdr_amount: float # 0..1
1935
+ hdr_knee: float # 0..1
1936
+ luma_only: bool
1937
+ luma_mode: str # e.g. "rec709"
1938
+ luma_blend: float #0..1 (0=normal linked, 1=pure luma-only)
1939
+ """
1869
1940
  def __init__(self, parent=None, initial: dict | None = None):
1870
1941
  super().__init__(parent)
1871
1942
  self.setWindowTitle("Statistical Stretch — Preset")
1872
1943
  init = dict(initial or {})
1873
1944
 
1945
+ # --- Target median ---
1874
1946
  self.spin_target = QDoubleSpinBox()
1875
- self.spin_target.setRange(0.0, 1.0); self.spin_target.setDecimals(3)
1947
+ self.spin_target.setRange(0.0, 1.0)
1948
+ self.spin_target.setDecimals(3)
1876
1949
  self.spin_target.setSingleStep(0.01)
1877
1950
  self.spin_target.setValue(float(init.get("target_median", 0.25)))
1878
1951
 
1952
+ # --- Linked / Normalize ---
1879
1953
  self.chk_linked = QCheckBox("Linked RGB channels")
1880
1954
  self.chk_linked.setChecked(bool(init.get("linked", False)))
1881
1955
 
1882
1956
  self.chk_normalize = QCheckBox("Normalize to [0..1]")
1883
1957
  self.chk_normalize.setChecked(bool(init.get("normalize", False)))
1884
1958
 
1959
+ # --- Curves ---
1960
+ self.chk_curves = QCheckBox("Apply Curves")
1961
+ self.chk_curves.setChecked(bool(init.get("apply_curves", False)))
1962
+
1885
1963
  self.spin_curves = QDoubleSpinBox()
1886
- self.spin_curves.setRange(0.0, 1.0); self.spin_curves.setDecimals(2)
1964
+ self.spin_curves.setRange(0.0, 1.0)
1965
+ self.spin_curves.setDecimals(2)
1887
1966
  self.spin_curves.setSingleStep(0.05)
1888
- self.spin_curves.setValue(float(init.get("curves_boost", 0.0 if not init.get("apply_curves") else 0.20)))
1967
+ self.spin_curves.setValue(float(init.get("curves_boost", 0.0)))
1968
+ self.spin_curves.setEnabled(self.chk_curves.isChecked())
1969
+ self.chk_curves.toggled.connect(self.spin_curves.setEnabled)
1970
+
1971
+ # --- Blackpoint sigma ---
1972
+ self.spin_bp = QDoubleSpinBox()
1973
+ self.spin_bp.setRange(0.0, 1.0)
1974
+ self.spin_bp.setDecimals(2)
1975
+ self.spin_bp.setSingleStep(0.05)
1976
+ self.spin_bp.setValue(float(init.get("blackpoint_sigma", 0.0)))
1977
+
1978
+ self.chk_no_black_clip = QCheckBox("No black clip")
1979
+ self.chk_no_black_clip.setChecked(bool(init.get("no_black_clip", False)))
1980
+
1981
+ # --- HDR compress ---
1982
+ self.chk_hdr = QCheckBox("HDR compression")
1983
+ self.chk_hdr.setChecked(bool(init.get("hdr_compress", False)))
1984
+
1985
+ self.spin_hdr_amt = QDoubleSpinBox()
1986
+ self.spin_hdr_amt.setRange(0.0, 1.0)
1987
+ self.spin_hdr_amt.setDecimals(2)
1988
+ self.spin_hdr_amt.setSingleStep(0.05)
1989
+ self.spin_hdr_amt.setValue(float(init.get("hdr_amount", 0.0)))
1990
+
1991
+ self.spin_hdr_knee = QDoubleSpinBox()
1992
+ self.spin_hdr_knee.setRange(0.0, 1.0)
1993
+ self.spin_hdr_knee.setDecimals(2)
1994
+ self.spin_hdr_knee.setSingleStep(0.05)
1995
+ self.spin_hdr_knee.setValue(float(init.get("hdr_knee", 0.5)))
1996
+
1997
+ def _set_hdr_enabled(on: bool):
1998
+ on = bool(on)
1999
+ self.spin_hdr_amt.setEnabled(on)
2000
+ self.spin_hdr_knee.setEnabled(on)
2001
+
2002
+ _set_hdr_enabled(self.chk_hdr.isChecked())
2003
+ self.chk_hdr.toggled.connect(_set_hdr_enabled)
2004
+
2005
+ # --- Luma only ---
2006
+ self.chk_luma_only = QCheckBox("Luma-only mode")
2007
+ self.chk_luma_only.setChecked(bool(init.get("luma_only", False)))
2008
+
2009
+ self.cmb_luma = QComboBox()
2010
+ # keep in sync with your tool’s supported modes
2011
+ self.cmb_luma.addItems(["rec709", "avg", "hsp", "max"])
2012
+ init_mode = str(init.get("luma_mode", "rec709") or "rec709")
2013
+ idx = self.cmb_luma.findText(init_mode)
2014
+ if idx >= 0:
2015
+ self.cmb_luma.setCurrentIndex(idx)
2016
+
2017
+ self.spin_luma_blend = QDoubleSpinBox()
2018
+ self.spin_luma_blend.setRange(0.0, 1.0)
2019
+ self.spin_luma_blend.setDecimals(2)
2020
+ self.spin_luma_blend.setSingleStep(0.05)
2021
+ self.spin_luma_blend.setValue(float(init.get("luma_blend", 0.70)))
2022
+
2023
+ def _set_luma_enabled(on: bool):
2024
+ on = bool(on)
2025
+ self.cmb_luma.setEnabled(on)
2026
+ self.spin_luma_blend.setEnabled(on)
2027
+
2028
+ _set_luma_enabled(self.chk_luma_only.isChecked())
2029
+ self.chk_luma_only.toggled.connect(_set_luma_enabled)
2030
+
2031
+ # --- Layout ---
2032
+ form = QFormLayout()
1889
2033
 
1890
- form = QFormLayout(self)
1891
2034
  form.addRow("Target median:", self.spin_target)
1892
2035
  form.addRow("", self.chk_linked)
1893
2036
  form.addRow("", self.chk_normalize)
2037
+
2038
+ form.addRow("", QLabel("— Tone shaping —"))
2039
+ form.addRow("", self.chk_curves)
1894
2040
  form.addRow("Curves boost (0–1):", self.spin_curves)
1895
- form.addRow(QLabel("Curves are applied only if boost > 0."))
1896
2041
 
1897
- btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1898
- btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2042
+ form.addRow("", QLabel("— Blackpoint / HDR —"))
2043
+ form.addRow("Blackpoint σ (0–1):", self.spin_bp)
2044
+ form.addRow("", self.chk_no_black_clip)
2045
+
2046
+ form.addRow("", self.chk_hdr)
2047
+ form.addRow("HDR amount (0–1):", self.spin_hdr_amt)
2048
+ form.addRow("HDR knee (0–1):", self.spin_hdr_knee)
2049
+
2050
+ form.addRow("", QLabel("— Luma mode —"))
2051
+ form.addRow("", self.chk_luma_only)
2052
+ form.addRow("Luma mode:", self.cmb_luma)
2053
+ form.addRow("Luma blend (0–1):", self.spin_luma_blend)
2054
+ btns = QDialogButtonBox(
2055
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
2056
+ parent=self
2057
+ )
2058
+ btns.accepted.connect(self.accept)
2059
+ btns.rejected.connect(self.reject)
1899
2060
  form.addRow(btns)
1900
2061
 
2062
+ self.setLayout(form)
2063
+
1901
2064
  def result_dict(self) -> dict:
1902
- boost = float(self.spin_curves.value())
2065
+ hdr_on = bool(self.chk_hdr.isChecked())
2066
+ curves_on = bool(self.chk_curves.isChecked())
2067
+ luma_on = bool(self.chk_luma_only.isChecked())
2068
+
1903
2069
  return {
1904
2070
  "target_median": float(self.spin_target.value()),
1905
2071
  "linked": bool(self.chk_linked.isChecked()),
1906
2072
  "normalize": bool(self.chk_normalize.isChecked()),
1907
- "apply_curves": bool(boost > 0.0),
1908
- "curves_boost": boost,
2073
+
2074
+ "apply_curves": curves_on,
2075
+ "curves_boost": float(self.spin_curves.value()) if curves_on else 0.0,
2076
+
2077
+ "blackpoint_sigma": float(self.spin_bp.value()),
2078
+ "no_black_clip": bool(self.chk_no_black_clip.isChecked()),
2079
+
2080
+ "hdr_compress": hdr_on,
2081
+ "hdr_amount": float(self.spin_hdr_amt.value()) if hdr_on else 0.0,
2082
+ "hdr_knee": float(self.spin_hdr_knee.value()) if hdr_on else 0.0,
2083
+
2084
+ "luma_only": luma_on,
2085
+ "luma_mode": str(self.cmb_luma.currentText()) if luma_on else "rec709",
2086
+ "luma_blend": float(self.spin_luma_blend.value()) if luma_on else 0.0,
1909
2087
  }
1910
-
1911
2088
 
1912
2089
  class _StarStretchPresetDialog(QDialog):
1913
2090
  def __init__(self, parent=None, initial: dict | None = None):
@@ -3041,3 +3218,30 @@ class _RGBAlignPresetDialog(QDialog):
3041
3218
  "new_doc": bool(self.chk_new.isChecked()),
3042
3219
  }
3043
3220
 
3221
+ class _GeomRotateAnyPresetDialog(QDialog):
3222
+ def __init__(self, parent=None, initial: dict | None = None):
3223
+ super().__init__(parent)
3224
+ self.setWindowTitle("Arbitrary Rotation — Preset")
3225
+ init = dict(initial or {})
3226
+
3227
+ self.spin_angle = QDoubleSpinBox()
3228
+ self.spin_angle.setRange(-360.0, 360.0)
3229
+ self.spin_angle.setDecimals(2)
3230
+ self.spin_angle.setSingleStep(0.25)
3231
+ self.spin_angle.setValue(float(init.get("angle_deg", init.get("angle", 0.0))))
3232
+
3233
+ form = QFormLayout(self)
3234
+ form.addRow("Angle (degrees):", self.spin_angle)
3235
+
3236
+ btns = QDialogButtonBox(
3237
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
3238
+ parent=self
3239
+ )
3240
+ btns.accepted.connect(self.accept)
3241
+ btns.rejected.connect(self.reject)
3242
+ form.addRow(btns)
3243
+
3244
+ def result_dict(self) -> dict:
3245
+ return {
3246
+ "angle_deg": float(self.spin_angle.value()),
3247
+ }