setiastrosuitepro 1.6.1.post1__py3-none-any.whl → 1.6.4__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 (139) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/images/rotatearbitrary.png +0 -0
  3. setiastro/qml/ResourceMonitor.qml +126 -0
  4. setiastro/saspro/__main__.py +162 -25
  5. setiastro/saspro/_generated/build_info.py +2 -1
  6. setiastro/saspro/abe.py +62 -11
  7. setiastro/saspro/aberration_ai.py +3 -3
  8. setiastro/saspro/add_stars.py +5 -2
  9. setiastro/saspro/astrobin_exporter.py +3 -0
  10. setiastro/saspro/astrospike_python.py +3 -1
  11. setiastro/saspro/autostretch.py +4 -2
  12. setiastro/saspro/backgroundneutral.py +60 -9
  13. setiastro/saspro/batch_convert.py +3 -0
  14. setiastro/saspro/batch_renamer.py +3 -0
  15. setiastro/saspro/blemish_blaster.py +3 -0
  16. setiastro/saspro/blink_comparator_pro.py +474 -251
  17. setiastro/saspro/cheat_sheet.py +50 -15
  18. setiastro/saspro/clahe.py +27 -1
  19. setiastro/saspro/comet_stacking.py +103 -38
  20. setiastro/saspro/convo.py +3 -0
  21. setiastro/saspro/copyastro.py +3 -0
  22. setiastro/saspro/cosmicclarity.py +70 -45
  23. setiastro/saspro/crop_dialog_pro.py +28 -1
  24. setiastro/saspro/curve_editor_pro.py +18 -0
  25. setiastro/saspro/debayer.py +3 -0
  26. setiastro/saspro/doc_manager.py +40 -17
  27. setiastro/saspro/fitsmodifier.py +3 -0
  28. setiastro/saspro/frequency_separation.py +8 -2
  29. setiastro/saspro/function_bundle.py +18 -16
  30. setiastro/saspro/generate_translations.py +715 -1
  31. setiastro/saspro/ghs_dialog_pro.py +3 -0
  32. setiastro/saspro/graxpert.py +3 -0
  33. setiastro/saspro/gui/main_window.py +364 -92
  34. setiastro/saspro/gui/mixins/dock_mixin.py +119 -7
  35. setiastro/saspro/gui/mixins/file_mixin.py +7 -0
  36. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  37. setiastro/saspro/gui/mixins/menu_mixin.py +29 -0
  38. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  39. setiastro/saspro/gui/statistics_dialog.py +47 -0
  40. setiastro/saspro/halobgon.py +29 -3
  41. setiastro/saspro/histogram.py +3 -0
  42. setiastro/saspro/history_explorer.py +2 -0
  43. setiastro/saspro/i18n.py +22 -10
  44. setiastro/saspro/image_combine.py +3 -0
  45. setiastro/saspro/image_peeker_pro.py +3 -0
  46. setiastro/saspro/imageops/stretch.py +5 -13
  47. setiastro/saspro/isophote.py +3 -0
  48. setiastro/saspro/legacy/numba_utils.py +64 -47
  49. setiastro/saspro/linear_fit.py +3 -0
  50. setiastro/saspro/live_stacking.py +13 -2
  51. setiastro/saspro/mask_creation.py +3 -0
  52. setiastro/saspro/mfdeconv.py +5 -0
  53. setiastro/saspro/morphology.py +30 -5
  54. setiastro/saspro/multiscale_decomp.py +713 -256
  55. setiastro/saspro/nbtorgb_stars.py +12 -2
  56. setiastro/saspro/numba_utils.py +148 -47
  57. setiastro/saspro/ops/scripts.py +77 -17
  58. setiastro/saspro/ops/settings.py +1 -43
  59. setiastro/saspro/perfect_palette_picker.py +1 -0
  60. setiastro/saspro/pixelmath.py +6 -2
  61. setiastro/saspro/plate_solver.py +1 -0
  62. setiastro/saspro/remove_green.py +18 -1
  63. setiastro/saspro/remove_stars.py +136 -162
  64. setiastro/saspro/remove_stars_preset.py +55 -13
  65. setiastro/saspro/resources.py +36 -10
  66. setiastro/saspro/rgb_combination.py +1 -0
  67. setiastro/saspro/rgbalign.py +4 -4
  68. setiastro/saspro/save_options.py +1 -0
  69. setiastro/saspro/selective_color.py +79 -20
  70. setiastro/saspro/sfcc.py +50 -8
  71. setiastro/saspro/shortcuts.py +94 -21
  72. setiastro/saspro/signature_insert.py +3 -0
  73. setiastro/saspro/stacking_suite.py +924 -446
  74. setiastro/saspro/star_alignment.py +291 -331
  75. setiastro/saspro/star_spikes.py +116 -32
  76. setiastro/saspro/star_stretch.py +38 -1
  77. setiastro/saspro/stat_stretch.py +35 -3
  78. setiastro/saspro/status_log_dock.py +1 -1
  79. setiastro/saspro/subwindow.py +63 -2
  80. setiastro/saspro/supernovaasteroidhunter.py +3 -0
  81. setiastro/saspro/swap_manager.py +77 -42
  82. setiastro/saspro/translations/all_source_strings.json +4726 -0
  83. setiastro/saspro/translations/ar_translations.py +4096 -0
  84. setiastro/saspro/translations/de_translations.py +441 -446
  85. setiastro/saspro/translations/es_translations.py +278 -32
  86. setiastro/saspro/translations/fr_translations.py +280 -32
  87. setiastro/saspro/translations/hi_translations.py +3803 -0
  88. setiastro/saspro/translations/integrate_translations.py +38 -1
  89. setiastro/saspro/translations/it_translations.py +1211 -145
  90. setiastro/saspro/translations/ja_translations.py +556 -307
  91. setiastro/saspro/translations/pt_translations.py +3316 -3322
  92. setiastro/saspro/translations/ru_translations.py +3082 -0
  93. setiastro/saspro/translations/saspro_ar.qm +0 -0
  94. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  95. setiastro/saspro/translations/saspro_de.qm +0 -0
  96. setiastro/saspro/translations/saspro_de.ts +14428 -133
  97. setiastro/saspro/translations/saspro_es.qm +0 -0
  98. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  99. setiastro/saspro/translations/saspro_fr.qm +0 -0
  100. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  101. setiastro/saspro/translations/saspro_hi.qm +0 -0
  102. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  103. setiastro/saspro/translations/saspro_it.qm +0 -0
  104. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  105. setiastro/saspro/translations/saspro_ja.qm +0 -0
  106. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  107. setiastro/saspro/translations/saspro_pt.qm +0 -0
  108. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  109. setiastro/saspro/translations/saspro_ru.qm +0 -0
  110. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  111. setiastro/saspro/translations/saspro_sw.qm +0 -0
  112. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  113. setiastro/saspro/translations/saspro_uk.qm +0 -0
  114. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  115. setiastro/saspro/translations/saspro_zh.qm +0 -0
  116. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  117. setiastro/saspro/translations/sw_translations.py +3897 -0
  118. setiastro/saspro/translations/uk_translations.py +3929 -0
  119. setiastro/saspro/translations/zh_translations.py +283 -32
  120. setiastro/saspro/versioning.py +36 -5
  121. setiastro/saspro/view_bundle.py +20 -17
  122. setiastro/saspro/wavescale_hdr.py +22 -1
  123. setiastro/saspro/wavescalede.py +23 -1
  124. setiastro/saspro/whitebalance.py +39 -3
  125. setiastro/saspro/widgets/minigame/game.js +991 -0
  126. setiastro/saspro/widgets/minigame/index.html +53 -0
  127. setiastro/saspro/widgets/minigame/style.css +241 -0
  128. setiastro/saspro/widgets/resource_monitor.py +263 -0
  129. setiastro/saspro/widgets/spinboxes.py +18 -0
  130. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  131. setiastro/saspro/wimi.py +100 -80
  132. setiastro/saspro/wims.py +33 -33
  133. setiastro/saspro/window_shelf.py +2 -2
  134. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +15 -4
  135. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +139 -115
  136. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  137. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  138. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  139. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -467,7 +467,7 @@ class SelectiveColorCorrection(QDialog):
467
467
 
468
468
  self.img = np.clip(self.document.image.astype(np.float32), 0.0, 1.0)
469
469
  self.preview_img = self.img.copy()
470
-
470
+ self._syncing_hue = False
471
471
  self._imported_mask_full = None # full-res mask (H x W) float32 0..1
472
472
  self._imported_mask_name = None # nice label to show in UI
473
473
  self._use_imported_mask = False # checkbox state mirror
@@ -555,6 +555,36 @@ class SelectiveColorCorrection(QDialog):
555
555
  self.hue_wheel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
556
556
  gl.addWidget(self.hue_wheel, 1, 0, 7, 2)
557
557
 
558
+ # Wheel -> sliders/spins (so dragging wheel updates UI and mask)
559
+ def _wheel_to_sliders(s: int, e: int):
560
+ # If user is dragging the wheel, we’re now custom
561
+ if not self._setting_preset and self.dd_preset.currentText() != "Custom":
562
+ self.dd_preset.blockSignals(True)
563
+ self.dd_preset.setCurrentText("Custom")
564
+ self.dd_preset.blockSignals(False)
565
+
566
+ # Update BOTH sliders and spins, without ping-pong
567
+ self._syncing_hue = True
568
+ try:
569
+ s = int(s) % 360
570
+ e = int(e) % 360
571
+
572
+ for w, val in (
573
+ (self.sl_h1, s), (self.sp_h1, s),
574
+ (self.sl_h2, e), (self.sp_h2, e),
575
+ ):
576
+ w.blockSignals(True)
577
+ w.setValue(val)
578
+ w.blockSignals(False)
579
+ finally:
580
+ self._syncing_hue = False
581
+
582
+ self._schedule_mask()
583
+
584
+ self.hue_wheel.rangeChanged.connect(_wheel_to_sliders)
585
+
586
+
587
+
558
588
  # Helper: integer slider + spin (0..360)
559
589
  def _deg_pair(grid: QGridLayout, label: str, row: int):
560
590
  grid.addWidget(QLabel(label), row, 2)
@@ -574,7 +604,8 @@ class SelectiveColorCorrection(QDialog):
574
604
 
575
605
  # Row 3: chroma + lightness
576
606
  gl.addWidget(QLabel("Min chroma:"), 3, 2)
577
- self.ds_minC = QDoubleSpinBox(); self.ds_minC.setRange(0,1); self.ds_minC.setSingleStep(0.05); self.ds_minC.setValue(0.0)
607
+ self.ds_minC = QDoubleSpinBox(); self.ds_minC.setRange(0,1); self.ds_minC.setSingleStep(0.05); self.ds_minC.setValue(0.05)
608
+
578
609
  self.ds_minC.valueChanged.connect(self._recompute_mask_and_preview)
579
610
  gl.addWidget(self.ds_minC, 3, 3)
580
611
 
@@ -741,10 +772,12 @@ class SelectiveColorCorrection(QDialog):
741
772
  right.addLayout(zoom_row)
742
773
 
743
774
  self.lbl_help = QLabel(
744
- "🖱️ <b>Click</b>: pick hue &nbsp;•&nbsp; "
775
+ "🖱️ <b>Click</b>: show hue &nbsp;•&nbsp; "
776
+ "<b>Shift + Click</b>: select that color &nbsp;•&nbsp; "
745
777
  "<b>Ctrl + Click & Drag</b>: pan &nbsp;•&nbsp; "
746
778
  "<b>Ctrl + Wheel</b>: zoom"
747
779
  )
780
+
748
781
  self.lbl_help.setWordWrap(True)
749
782
  self.lbl_help.setTextFormat(Qt.TextFormat.RichText)
750
783
  self.lbl_help.setStyleSheet("color: #888; font-size: 11px;")
@@ -797,12 +830,20 @@ class SelectiveColorCorrection(QDialog):
797
830
  w.valueChanged.connect(self._schedule_adjustments)
798
831
 
799
832
  def _sliders_to_wheel(_=None):
833
+ if getattr(self, "_syncing_hue", False):
834
+ return
835
+
800
836
  if not self._setting_preset and self.dd_preset.currentText() != "Custom":
837
+ self.dd_preset.blockSignals(True)
801
838
  self.dd_preset.setCurrentText("Custom")
802
- s = int(self.sp_h1.value()); e = int(self.sp_h2.value())
839
+ self.dd_preset.blockSignals(False)
840
+
841
+ s = int(self.sp_h1.value())
842
+ e = int(self.sp_h2.value())
803
843
  self.hue_wheel.setRange(s, e, notify=False)
804
844
  self._schedule_mask()
805
845
 
846
+
806
847
  self.sp_h1.valueChanged.connect(_sliders_to_wheel)
807
848
  self.sp_h2.valueChanged.connect(_sliders_to_wheel)
808
849
  self.sl_h1.valueChanged.connect(_sliders_to_wheel)
@@ -973,7 +1014,7 @@ class SelectiveColorCorrection(QDialog):
973
1014
  w.setValue(int(val))
974
1015
  w.blockSignals(False)
975
1016
 
976
- setv(self.ds_minC, 0.0)
1017
+ setv(self.ds_minC, 0.05)
977
1018
  setv(self.ds_minL, 0.0)
978
1019
  setv(self.ds_maxL, 1.0)
979
1020
  setv(self.ds_smooth, 10.0)
@@ -1018,23 +1059,27 @@ class SelectiveColorCorrection(QDialog):
1018
1059
  self._recompute_mask_and_preview()
1019
1060
 
1020
1061
 
1021
- def _schedule_adjustments(self, delay_ms: int | None = None):
1062
+ def _schedule_adjustments(self, *_, delay_ms: int | None = None):
1022
1063
  if delay_ms is None:
1023
1064
  delay_ms = getattr(self, "_adj_delay_ms", 200)
1024
- # if called very early, just no-op safely
1065
+
1025
1066
  if not hasattr(self, "_adj_timer"):
1026
1067
  return
1027
- self._adj_timer.stop()
1028
- self._adj_timer.start(int(delay_ms))
1029
1068
 
1069
+ ms = max(1, int(delay_ms)) # never allow 0/negative
1070
+ self._adj_timer.stop()
1071
+ self._adj_timer.start(ms)
1030
1072
 
1031
- def _schedule_mask(self, delay_ms: int | None = None):
1032
- """Debounce mask recomputation for hue changes."""
1073
+ def _schedule_mask(self, *_, delay_ms: int | None = None):
1033
1074
  if delay_ms is None:
1034
- delay_ms = self._mask_delay_ms
1035
- # restart the timer on every change
1075
+ delay_ms = getattr(self, "_mask_delay_ms", 200)
1076
+
1077
+ if not hasattr(self, "_mask_timer"):
1078
+ return
1079
+
1080
+ ms = max(1, int(delay_ms))
1036
1081
  self._mask_timer.stop()
1037
- self._mask_timer.start(int(delay_ms))
1082
+ self._mask_timer.start(ms)
1038
1083
 
1039
1084
 
1040
1085
  def _sample_hue_deg_from_base(self, x: int, y: int) -> float | None:
@@ -1190,16 +1235,30 @@ class SelectiveColorCorrection(QDialog):
1190
1235
  intervals = _PRESETS.get(txt, [])
1191
1236
  if intervals:
1192
1237
  lo, hi = (intervals[0][0], intervals[-1][1]) if len(intervals) > 1 else intervals[0]
1193
- self.hue_wheel.setRange(int(lo), int(hi), notify=False) # update wheel silently
1194
- self.hue_wheel.update() # ensure repaint
1195
- self.sp_h1.blockSignals(True); self.sp_h2.blockSignals(True)
1196
- self.sp_h1.setValue(int(lo)); self.sp_h2.setValue(int(hi))
1197
- self.sp_h1.blockSignals(False); self.sp_h2.blockSignals(False)
1238
+
1239
+ # --- NEW: keep wheel + sliders + spins all in sync ---
1240
+ self._syncing_hue = True
1241
+ try:
1242
+ # update wheel silently
1243
+ self.hue_wheel.setRange(int(lo), int(hi), notify=False)
1244
+
1245
+ # update both sliders and spins (no ping-pong)
1246
+ for w, val in (
1247
+ (self.sl_h1, int(lo)), (self.sp_h1, int(lo)),
1248
+ (self.sl_h2, int(hi)), (self.sp_h2, int(hi)),
1249
+ ):
1250
+ w.blockSignals(True)
1251
+ w.setValue(val)
1252
+ w.blockSignals(False)
1253
+
1254
+ self.hue_wheel.update() # ensure repaint
1255
+ finally:
1256
+ self._syncing_hue = False
1257
+
1198
1258
  self._recompute_mask_and_preview()
1199
1259
  finally:
1200
1260
  self._setting_preset = False
1201
1261
 
1202
-
1203
1262
  def _downsample(self, img, max_dim=1024):
1204
1263
  h, w = img.shape[:2]
1205
1264
  s = max(h, w)
setiastro/saspro/sfcc.py CHANGED
@@ -346,6 +346,9 @@ class SFCCDialog(QDialog):
346
346
  def __init__(self, doc_manager, sasp_data_path, parent=None):
347
347
  super().__init__(parent)
348
348
  self.setWindowTitle(self.tr("Spectral Flux Color Calibration"))
349
+ self.setWindowFlag(Qt.WindowType.Window, True)
350
+ self.setWindowModality(Qt.WindowModality.NonModal)
351
+ self.setModal(False)
349
352
  self.setMinimumSize(800, 600)
350
353
 
351
354
  self.doc_manager = doc_manager
@@ -1155,18 +1158,56 @@ class SFCCDialog(QDialog):
1155
1158
  diag_meas_BG, diag_exp_BG = [], []
1156
1159
  enriched = []
1157
1160
 
1161
+ # --- Optimization: Pre-calculate integrals for unique templates ---
1162
+ unique_simbad_types = set(m["template"] for m in raw_matches)
1163
+
1164
+ # Map simbad_type -> pickles_template_name
1165
+ simbad_to_pickles = {}
1166
+ pickles_templates_needed = set()
1167
+
1168
+ for sp in unique_simbad_types:
1169
+ cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
1170
+ if cands:
1171
+ pickles_name = cands[0]
1172
+ simbad_to_pickles[sp] = pickles_name
1173
+ pickles_templates_needed.add(pickles_name)
1174
+
1175
+ # Pre-calc integrals for each unique Pickles template
1176
+ # Cache structure: template_name -> (S_sr, S_sg, S_sb)
1177
+ template_integrals = {}
1178
+
1179
+ # Cache for load_sed to avoid re-reading even across different calls if desired,
1180
+ # but here we just optimize the loop.
1181
+
1182
+ for pname in pickles_templates_needed:
1183
+ try:
1184
+ wl_s, fl_s = load_sed(pname)
1185
+ fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
1186
+
1187
+ S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1188
+ S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1189
+ S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1190
+
1191
+ template_integrals[pname] = (S_sr, S_sg, S_sb)
1192
+ except Exception as e:
1193
+ print(f"[SFCC] Warning: failed to load/integrate template {pname}: {e}")
1194
+
1195
+ # --- Main Match Loop ---
1158
1196
  for m in raw_matches:
1159
1197
  xi, yi, sp = m["x_pix"], m["y_pix"], m["template"]
1160
1198
  Rm = float(base[yi, xi, 0]); Gm = float(base[yi, xi, 1]); Bm = float(base[yi, xi, 2])
1161
1199
  if Gm <= 0: continue
1162
1200
 
1163
- cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
1164
- if not cands: continue
1165
- wl_s, fl_s = load_sed(cands[0])
1166
- fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
1167
- S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1168
- S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1169
- S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1201
+ # 1. Resolve Simbad -> Pickles
1202
+ pname = simbad_to_pickles.get(sp)
1203
+ if not pname: continue
1204
+
1205
+ # 2. Retrieve pre-calced integrals
1206
+ integrals = template_integrals.get(pname)
1207
+ if not integrals: continue
1208
+
1209
+ S_sr, S_sg, S_sb = integrals
1210
+
1170
1211
  if S_sg <= 0: continue
1171
1212
 
1172
1213
  exp_RG = S_sr / S_sg; exp_BG = S_sb / S_sg
@@ -1180,7 +1221,8 @@ class SFCCDialog(QDialog):
1180
1221
  "S_star_R": S_sr, "S_star_G": S_sg, "S_star_B": S_sb,
1181
1222
  "exp_RG": exp_RG, "exp_BG": exp_BG
1182
1223
  })
1183
- self._last_matched = enriched # <-- missing in SASpro
1224
+
1225
+ self._last_matched = enriched
1184
1226
  diag_meas_RG = np.array(diag_meas_RG); diag_exp_RG = np.array(diag_exp_RG)
1185
1227
  diag_meas_BG = np.array(diag_meas_BG); diag_exp_BG = np.array(diag_exp_BG)
1186
1228
  if diag_meas_RG.size == 0 or diag_meas_BG.size == 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,28 +487,42 @@ 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._set_action_hidden(act, True))
471
497
 
472
498
  # (Optional) teach users about Alt+Drag:
473
499
  m.addSeparator()
474
- tip = m.addAction("Tip: Alt+Drag to create")
500
+ tip = m.addAction(self.tr("Tip: Alt+Drag to create"))
475
501
  tip.setEnabled(False)
476
502
 
477
503
  m.exec(gpos)
478
504
 
505
+
479
506
  def contextMenuEvent(self, ev):
480
507
  # Right-click on empty toolbar area
481
508
  m = QMenu(self)
482
509
 
510
+ # 1. Lock/Unlock Action
511
+ is_locked = self._is_locked()
512
+ act_lock = m.addAction(self.tr("Lock Toolbar Icons"))
513
+ act_lock.setCheckable(True)
514
+ act_lock.setChecked(is_locked)
515
+
516
+ def _toggle_lock(checked):
517
+ self._set_locked(checked)
518
+
519
+ act_lock.triggered.connect(_toggle_lock)
520
+
521
+ m.addSeparator()
522
+
483
523
  # Submenu listing hidden actions for this toolbar
484
524
  hidden = self._load_hidden_set()
485
- sub = m.addMenu("Show hidden…")
525
+ sub = m.addMenu(self.tr("Show hidden…"))
486
526
 
487
527
  # Build list from actions that are currently invisible
488
528
  any_hidden = False
@@ -496,7 +536,7 @@ class DraggableToolBar(QToolBar):
496
536
  sub.setEnabled(False)
497
537
 
498
538
  m.addSeparator()
499
- m.addAction("Reset hidden icons", self._reset_hidden_icons)
539
+ m.addAction(self.tr("Reset hidden icons"), self._reset_hidden_icons)
500
540
 
501
541
  m.exec(ev.globalPos())
502
542
 
@@ -515,7 +555,7 @@ _PRESET_UI_IDS = {
515
555
  "remove_green","star_align","background_neutral","white_balance","clahe",
516
556
  "morphology","pixel_math","rgb_align","signature_insert","signature_adder",
517
557
  "signature","halo_b_gon","geom_rescale","rescale","debayer","image_combine",
518
- "star_spikes","diffraction_spikes", "multiscale_decomp",
558
+ "star_spikes","diffraction_spikes", "multiscale_decomp","geom_rotate_any",
519
559
  }
520
560
 
521
561
  def _has_preset_editor_for_command(command_id: str) -> bool:
@@ -548,6 +588,12 @@ def _open_preset_editor_for_command(parent, command_id: str, initial: dict | Non
548
588
  })
549
589
  return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
550
590
 
591
+ if command_id == "geom_rotate_any":
592
+ dlg = _GeomRotateAnyPresetDialog(parent, initial=cur or {
593
+ "angle_deg": 0.0,
594
+ })
595
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
596
+
551
597
  if command_id == "curves":
552
598
  dlg = _CurvesPresetDialog(parent, initial=cur or {"shape":"linear","amount":0.5,"mode":"K (Brightness)"})
553
599
  return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
@@ -741,18 +787,18 @@ class ShortcutButton(QToolButton):
741
787
  # --- Context menu (run / preset / delete) ----------------------------
742
788
  def _context_menu(self, pos):
743
789
  m = QMenu(self)
744
- m.addAction("Run", lambda: self._mgr.trigger(self.command_id))
790
+ m.addAction(self.tr("Run"), lambda: self._mgr.trigger(self.command_id))
745
791
  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
792
+ m.addAction(self.tr("Edit Preset…"), self._edit_preset_ui)
793
+ m.addAction(self.tr("Clear Preset"), lambda: self._save_preset(None))
794
+ m.addAction(self.tr("Rename…"), self._rename) # ← NEW
749
795
  m.addSeparator()
750
- m.addAction("Delete", self._delete)
796
+ m.addAction(self.tr("Delete"), self._delete)
751
797
  m.exec(self.mapToGlobal(pos))
752
798
 
753
799
  def _rename(self):
754
800
  current = self.text()
755
- new_name, ok = QInputDialog.getText(self, "Rename Shortcut", "Name:", text=current)
801
+ new_name, ok = QInputDialog.getText(self, self.tr("Rename Shortcut"), self.tr("Name:"), text=current)
756
802
  if not ok or not new_name.strip():
757
803
  return
758
804
  self.setText(new_name.strip())
@@ -764,21 +810,21 @@ class ShortcutButton(QToolButton):
764
810
  result = _open_preset_editor_for_command(self, cid, cur)
765
811
  if result is not None:
766
812
  self._save_preset(result)
767
- QMessageBox.information(self, "Preset saved", "Preset stored on shortcut.")
813
+ QMessageBox.information(self, self.tr("Preset saved"), self.tr("Preset stored on shortcut."))
768
814
  return
769
815
 
770
816
  # Fallback: JSON editor
771
817
  raw = json.dumps(cur or {}, indent=2)
772
- text, ok = QInputDialog.getMultiLineText(self, "Edit Preset (JSON)", "Preset:", raw)
818
+ text, ok = QInputDialog.getMultiLineText(self, self.tr("Edit Preset (JSON)"), self.tr("Preset:"), raw)
773
819
  if ok:
774
820
  try:
775
821
  preset = json.loads(text or "{}")
776
822
  if not isinstance(preset, dict):
777
- raise ValueError("Preset must be a JSON object")
823
+ raise ValueError(self.tr("Preset must be a JSON object"))
778
824
  self._save_preset(preset)
779
- QMessageBox.information(self, "Preset saved", "Preset stored on shortcut.")
825
+ QMessageBox.information(self, self.tr("Preset saved"), self.tr("Preset stored on shortcut."))
780
826
  except Exception as e:
781
- QMessageBox.warning(self, "Invalid JSON", str(e))
827
+ QMessageBox.warning(self, self.tr("Invalid JSON"), str(e))
782
828
 
783
829
 
784
830
  def _start_command_drag(self):
@@ -1066,11 +1112,11 @@ class ShortcutCanvas(QWidget):
1066
1112
  def contextMenuEvent(self, e):
1067
1113
  menu = QMenu(self)
1068
1114
  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)
1115
+ a_del = menu.addAction(self.tr("Delete Selected"), self._mgr.delete_selected); a_del.setEnabled(has_sel)
1116
+ a_clr = menu.addAction(self.tr("Clear Selection"), self._mgr.clear_selection); a_clr.setEnabled(has_sel)
1071
1117
  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))
1118
+ a_vb = menu.addAction(self.tr("View Bundles…"), lambda: _open_view_bundles_from_canvas(self))
1119
+ a_fb = menu.addAction(self.tr("Function Bundles…"), lambda: _open_function_bundles_from_canvas(self))
1074
1120
  menu.exec(e.globalPos())
1075
1121
 
1076
1122
 
@@ -3041,3 +3087,30 @@ class _RGBAlignPresetDialog(QDialog):
3041
3087
  "new_doc": bool(self.chk_new.isChecked()),
3042
3088
  }
3043
3089
 
3090
+ class _GeomRotateAnyPresetDialog(QDialog):
3091
+ def __init__(self, parent=None, initial: dict | None = None):
3092
+ super().__init__(parent)
3093
+ self.setWindowTitle("Arbitrary Rotation — Preset")
3094
+ init = dict(initial or {})
3095
+
3096
+ self.spin_angle = QDoubleSpinBox()
3097
+ self.spin_angle.setRange(-360.0, 360.0)
3098
+ self.spin_angle.setDecimals(2)
3099
+ self.spin_angle.setSingleStep(0.25)
3100
+ self.spin_angle.setValue(float(init.get("angle_deg", init.get("angle", 0.0))))
3101
+
3102
+ form = QFormLayout(self)
3103
+ form.addRow("Angle (degrees):", self.spin_angle)
3104
+
3105
+ btns = QDialogButtonBox(
3106
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
3107
+ parent=self
3108
+ )
3109
+ btns.accepted.connect(self.accept)
3110
+ btns.rejected.connect(self.reject)
3111
+ form.addRow(btns)
3112
+
3113
+ def result_dict(self) -> dict:
3114
+ return {
3115
+ "angle_deg": float(self.spin_angle.value()),
3116
+ }
@@ -363,6 +363,9 @@ class SignatureInsertDialogPro(QDialog):
363
363
  def __init__(self, parent, doc, icon: QIcon | None = None):
364
364
  super().__init__(parent)
365
365
  self.setWindowTitle(self.tr("Signature / Insert"))
366
+ self.setWindowFlag(Qt.WindowType.Window, True)
367
+ self.setWindowModality(Qt.WindowModality.NonModal)
368
+ self.setModal(False)
366
369
  if icon:
367
370
  try: self.setWindowIcon(icon)
368
371
  except Exception as e: