setiastrosuitepro 1.6.2__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.
Files changed (72) hide show
  1. setiastro/images/rotatearbitrary.png +0 -0
  2. setiastro/saspro/_generated/build_info.py +2 -2
  3. setiastro/saspro/backgroundneutral.py +10 -1
  4. setiastro/saspro/blink_comparator_pro.py +474 -251
  5. setiastro/saspro/crop_dialog_pro.py +11 -1
  6. setiastro/saspro/doc_manager.py +1 -1
  7. setiastro/saspro/function_bundle.py +16 -16
  8. setiastro/saspro/gui/main_window.py +93 -64
  9. setiastro/saspro/gui/mixins/dock_mixin.py +31 -18
  10. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  11. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  12. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  13. setiastro/saspro/multiscale_decomp.py +710 -256
  14. setiastro/saspro/remove_stars_preset.py +55 -13
  15. setiastro/saspro/resources.py +30 -11
  16. setiastro/saspro/selective_color.py +79 -20
  17. setiastro/saspro/shortcuts.py +94 -21
  18. setiastro/saspro/stacking_suite.py +296 -107
  19. setiastro/saspro/star_alignment.py +275 -330
  20. setiastro/saspro/status_log_dock.py +1 -1
  21. setiastro/saspro/swap_manager.py +77 -42
  22. setiastro/saspro/translations/all_source_strings.json +1588 -516
  23. setiastro/saspro/translations/ar_translations.py +915 -684
  24. setiastro/saspro/translations/de_translations.py +442 -463
  25. setiastro/saspro/translations/es_translations.py +277 -47
  26. setiastro/saspro/translations/fr_translations.py +279 -47
  27. setiastro/saspro/translations/hi_translations.py +253 -21
  28. setiastro/saspro/translations/integrate_translations.py +3 -2
  29. setiastro/saspro/translations/it_translations.py +1211 -161
  30. setiastro/saspro/translations/ja_translations.py +3340 -3107
  31. setiastro/saspro/translations/pt_translations.py +3315 -3337
  32. setiastro/saspro/translations/ru_translations.py +351 -117
  33. setiastro/saspro/translations/saspro_ar.qm +0 -0
  34. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  35. setiastro/saspro/translations/saspro_de.qm +0 -0
  36. setiastro/saspro/translations/saspro_de.ts +14428 -133
  37. setiastro/saspro/translations/saspro_es.qm +0 -0
  38. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  39. setiastro/saspro/translations/saspro_fr.qm +0 -0
  40. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  41. setiastro/saspro/translations/saspro_hi.qm +0 -0
  42. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  43. setiastro/saspro/translations/saspro_it.qm +0 -0
  44. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  45. setiastro/saspro/translations/saspro_ja.qm +0 -0
  46. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  47. setiastro/saspro/translations/saspro_pt.qm +0 -0
  48. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  49. setiastro/saspro/translations/saspro_ru.qm +0 -0
  50. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  51. setiastro/saspro/translations/saspro_sw.qm +0 -0
  52. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  53. setiastro/saspro/translations/saspro_uk.qm +0 -0
  54. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  55. setiastro/saspro/translations/saspro_zh.qm +0 -0
  56. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  57. setiastro/saspro/translations/sw_translations.py +282 -56
  58. setiastro/saspro/translations/uk_translations.py +264 -35
  59. setiastro/saspro/translations/zh_translations.py +282 -47
  60. setiastro/saspro/view_bundle.py +17 -17
  61. setiastro/saspro/widgets/minigame/game.js +11 -6
  62. setiastro/saspro/widgets/resource_monitor.py +26 -0
  63. setiastro/saspro/widgets/spinboxes.py +18 -0
  64. setiastro/saspro/wimi.py +65 -65
  65. setiastro/saspro/wims.py +33 -33
  66. setiastro/saspro/window_shelf.py +2 -2
  67. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +7 -7
  68. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +72 -71
  69. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  70. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  71. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  72. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -12,7 +12,7 @@ from setiastro.saspro.legacy.image_manager import save_image, load_image
12
12
  # Reuse helpers & plumbing from the interactive module
13
13
  from .remove_stars import (
14
14
  _ProcThread, _ProcDialog,
15
- _stat_stretch_rgb, _stat_unstretch_rgb,
15
+ _mtf_params_unlinked, _apply_mtf_unlinked_rgb, _invert_mtf_unlinked_rgb,
16
16
  _active_mask3_from_doc, _mask_blend_with_doc_mask, _push_as_new_doc,
17
17
  _ensure_exec_bit,
18
18
  )
@@ -125,24 +125,48 @@ def _run_starnet_headless(main, doc, p):
125
125
  processing_image = processing_image.astype(np.float32, copy=False)
126
126
 
127
127
  is_linear = bool(p.get("linear", True))
128
- did_stretch = False
129
- stretch_params = None
128
+ did_stretch = is_linear
129
+
130
+ # sanitize + normalize if needed (keep exactly like interactive)
131
+ processing_image = np.nan_to_num(processing_image, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
132
+
133
+ scale_factor = float(np.max(processing_image)) if processing_image.size else 1.0
134
+ processing_norm = (processing_image / scale_factor) if scale_factor > 1.0 else processing_image
135
+ processing_norm = np.clip(processing_norm, 0.0, 1.0)
136
+
137
+ img_for_starnet = processing_norm
138
+
130
139
  if is_linear:
131
- processing_image, stretch_params = _stat_stretch_rgb(processing_image)
132
- did_stretch = True
133
- setattr(main, "_starnet_last_stretch_params", stretch_params)
140
+ mtf_params = _mtf_params_unlinked(processing_norm, shadows_clipping=-2.8, targetbg=0.25)
141
+ img_for_starnet = _apply_mtf_unlinked_rgb(processing_norm, mtf_params)
142
+
143
+ # stash for inverse step (same keys as interactive)
144
+ try:
145
+ setattr(main, "_starnet_stat_meta", {
146
+ "scheme": "siril_mtf",
147
+ "s": np.asarray(mtf_params["s"], dtype=np.float32),
148
+ "m": np.asarray(mtf_params["m"], dtype=np.float32),
149
+ "h": np.asarray(mtf_params["h"], dtype=np.float32),
150
+ "scale": float(scale_factor),
151
+ })
152
+ except Exception:
153
+ pass
134
154
  else:
135
- if hasattr(main, "_starnet_last_stretch_params"):
136
- delattr(main, "_starnet_last_stretch_params")
155
+ try:
156
+ if hasattr(main, "_starnet_stat_meta"):
157
+ delattr(main, "_starnet_stat_meta")
158
+ except Exception:
159
+ pass
160
+
137
161
 
138
162
  starnet_dir = os.path.dirname(exe) or os.getcwd()
139
163
  in_path = os.path.join(starnet_dir, "imagetoremovestars.tif")
140
164
  out_path = os.path.join(starnet_dir, "starless.tif")
141
165
 
142
166
  try:
143
- save_image(processing_image, in_path, original_format="tif",
144
- bit_depth="16-bit", original_header=None, is_mono=False,
145
- image_meta=None, file_meta=None)
167
+ save_image(img_for_starnet, in_path, original_format="tif",
168
+ bit_depth="16-bit", original_header=None, is_mono=False,
169
+ image_meta=None, file_meta=None)
146
170
  except Exception as e:
147
171
  QMessageBox.critical(main, "StarNet", f"Failed to write input TIFF:\n{e}")
148
172
  return
@@ -179,12 +203,30 @@ def _finish_starnet(main, doc, rc, dlg, in_path, out_path, did_stretch):
179
203
  starless_rgb = starless_rgb.astype(np.float32, copy=False)
180
204
 
181
205
  if did_stretch:
206
+ meta = getattr(main, "_starnet_stat_meta", None)
207
+ if isinstance(meta, dict) and meta.get("scheme") == "siril_mtf":
208
+ try:
209
+ p = {
210
+ "s": np.asarray(meta.get("s"), dtype=np.float32),
211
+ "m": np.asarray(meta.get("m"), dtype=np.float32),
212
+ "h": np.asarray(meta.get("h"), dtype=np.float32),
213
+ }
214
+ inv = _invert_mtf_unlinked_rgb(starless_rgb, p)
215
+ sc = float(meta.get("scale", 1.0))
216
+ if sc > 1.0:
217
+ inv *= sc
218
+ starless_rgb = np.clip(inv, 0.0, 1.0).astype(np.float32, copy=False)
219
+ except Exception:
220
+ pass
221
+
222
+ # cleanup so it can't leak
182
223
  try:
183
- params = getattr(main, "_starnet_last_stretch_params", None)
184
- if params: starless_rgb = _stat_unstretch_rgb(starless_rgb, params)
224
+ if hasattr(main, "_starnet_stat_meta"):
225
+ delattr(main, "_starnet_stat_meta")
185
226
  except Exception:
186
227
  pass
187
228
 
229
+
188
230
  # original as RGB
189
231
  orig = np.asarray(doc.image)
190
232
  if orig.ndim == 2: original_rgb = np.stack([orig]*3, axis=-1)
@@ -123,17 +123,31 @@ def _get_base_path() -> str:
123
123
 
124
124
 
125
125
  def _resource_path(filename: str) -> str:
126
- """Get full path to a resource file."""
127
126
  base = _get_base_path()
128
-
129
- # Check if it's an image file - look in images/ subdirectory
130
- if filename.endswith(('.png', '.ico', '.gif', '.icns', '.svg')):
131
- images_path = os.path.join(base, 'images', filename)
132
- if os.path.exists(images_path):
133
- return images_path
134
-
135
- # Fallback to root directory (for data files like .csv, .fits, etc.)
136
- return os.path.join(base, filename)
127
+ fn = filename
128
+
129
+ is_img = fn.lower().endswith(('.png','.ico','.gif','.icns','.svg','.jpg','.jpeg','.bmp'))
130
+ if is_img:
131
+ candidates = [
132
+ os.path.join(base, 'images', fn),
133
+ os.path.join(base, 'setiastro', 'images', fn),
134
+ os.path.join(base, 'setiastro', 'saspro', 'images', fn),
135
+ ]
136
+ for p in candidates:
137
+ if os.path.exists(p):
138
+ return p
139
+
140
+ # data / other files
141
+ candidates = [
142
+ os.path.join(base, fn),
143
+ os.path.join(base, 'setiastro', fn),
144
+ os.path.join(base, 'setiastro', 'saspro', fn),
145
+ ]
146
+ for p in candidates:
147
+ if os.path.exists(p):
148
+ return p
149
+
150
+ return os.path.join(base, fn)
137
151
 
138
152
 
139
153
  class Icons:
@@ -210,6 +224,7 @@ class Icons:
210
224
  ROTATE_CW = property(lambda self: _resource_path('rotateclockwise.png'))
211
225
  ROTATE_CCW = property(lambda self: _resource_path('rotatecounterclockwise.png'))
212
226
  ROTATE_180 = property(lambda self: _resource_path('rotate180.png'))
227
+ ROTATE_ANY = property(lambda self: _resource_path('rotatearbitrary.png'))
213
228
  RESCALE = property(lambda self: _resource_path('rescale.png'))
214
229
 
215
230
  # Masks
@@ -395,6 +410,7 @@ def _init_legacy_paths():
395
410
  'rotateclockwise_path': get_icon_path('rotateclockwise.png'),
396
411
  'rotatecounterclockwise_path': get_icon_path('rotatecounterclockwise.png'),
397
412
  'rotate180_path': get_icon_path('rotate180.png'),
413
+ 'rotatearbitrary_path': get_icon_path('rotatearbitrary.png'),
398
414
  'maskcreate_path': get_icon_path('maskcreate.png'),
399
415
  'maskapply_path': get_icon_path('maskapply.png'),
400
416
  'maskremove_path': get_icon_path('maskremove.png'),
@@ -471,9 +487,12 @@ globals().update(_legacy)
471
487
 
472
488
 
473
489
  # Background for startup
474
- background_startup_path = os.path.join(_get_base_path(), 'images', 'Background_startup.jpg')
490
+ background_startup_path = _resource_path('Background_startup.jpg')
475
491
  _legacy['background_startup_path'] = background_startup_path
476
492
 
493
+ # QML helper
494
+ resource_monitor_qml = _resource_path(os.path.join("qml", "ResourceMonitor.qml"))
495
+
477
496
  # Export list for `from setiastro.saspro.resources import *`
478
497
  __all__ = [
479
498
  'Icons', 'Resources',
@@ -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)
@@ -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
+ }