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.

Potentially problematic release.


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

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
@@ -302,7 +302,17 @@ class CropDialogPro(QDialog):
302
302
  row.addStretch(1)
303
303
  row.addWidget(QLabel(self.tr("Aspect Ratio:")))
304
304
  self.cmb_ar = QComboBox()
305
- self.cmb_ar.addItems([self.tr("Free"), self.tr("Original"), "1:1", "16:9", "9:16", "4:3"])
305
+ self.cmb_ar.addItems([
306
+ self.tr("Free"), self.tr("Original"),
307
+ "1:1",
308
+ "3:2", "2:3",
309
+ "4:3", "3:4",
310
+ "4:5", "5:4",
311
+ "16:9", "9:16",
312
+ "21:9", "9:21",
313
+ "2:1", "1:2",
314
+ "3:5", "5:3",
315
+ ])
306
316
  row.addWidget(self.cmb_ar)
307
317
  row.addStretch(1)
308
318
  main.addLayout(row)
@@ -1,4 +1,4 @@
1
- # pro/doc_manager.py
1
+ # saspro/doc_manager.py
2
2
  from __future__ import annotations
3
3
  from PyQt6.QtCore import QObject, pyqtSignal, Qt, QTimer
4
4
  from PyQt6.QtWidgets import QApplication, QMessageBox
@@ -202,7 +202,7 @@ class FunctionBundleChip(QWidget):
202
202
  from PyQt6.QtWidgets import QMenu # already imported at top, but safe
203
203
 
204
204
  m = QMenu(self)
205
- act_del = m.addAction("Delete Chip")
205
+ act_del = m.addAction(self._panel.tr("Delete Chip"))
206
206
  act = m.exec(ev.globalPos())
207
207
  if act is act_del:
208
208
  try:
@@ -333,7 +333,7 @@ class FunctionBundleDialog(QDialog):
333
333
  def __init__(self, parent: QWidget | None = None):
334
334
  super().__init__(parent)
335
335
  _pin_on_top_mac(self)
336
- self.setWindowTitle("Function Bundles")
336
+ self.setWindowTitle(self.tr("Function Bundles"))
337
337
  self.setWindowFlag(Qt.WindowType.Window, True)
338
338
  self.setWindowModality(Qt.WindowModality.NonModal)
339
339
  self.setModal(False)
@@ -350,9 +350,9 @@ class FunctionBundleDialog(QDialog):
350
350
  self.list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
351
351
  self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
352
352
 
353
- self.btn_new = QPushButton("New")
354
- self.btn_dup = QPushButton("Duplicate")
355
- self.btn_del = QPushButton("Delete")
353
+ self.btn_new = QPushButton(self.tr("New"))
354
+ self.btn_dup = QPushButton(self.tr("Duplicate"))
355
+ self.btn_del = QPushButton(self.tr("Delete"))
356
356
 
357
357
  # right: steps
358
358
  self.steps = QListWidget()
@@ -368,26 +368,26 @@ class FunctionBundleDialog(QDialog):
368
368
  self.steps.setResizeMode(QListView.ResizeMode.Adjust) # recompute item layout on width change
369
369
  self.steps.setUniformItemSizes(False)
370
370
 
371
- self.add_hint = QLabel("Drop shortcuts here to add steps")
371
+ self.add_hint = QLabel(self.tr("Drop shortcuts here to add steps"))
372
372
  self.add_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
373
373
  self.add_hint.setStyleSheet("color:#aaa; padding:6px; border:1px dashed #666; border-radius:6px;")
374
374
 
375
- self.btn_edit_preset = QPushButton("Edit Preset…")
375
+ self.btn_edit_preset = QPushButton(self.tr("Edit Preset…"))
376
376
  self.btn_edit_preset.setEnabled(False) # enabled when exactly one step is selected
377
377
 
378
- self.btn_remove = QPushButton("Remove Selected")
379
- self.btn_clear = QPushButton("Clear Steps")
380
- self.btn_up = QPushButton("▲ Move Up")
381
- self.btn_down = QPushButton("▼ Move Down")
378
+ self.btn_remove = QPushButton(self.tr("Remove Selected"))
379
+ self.btn_clear = QPushButton(self.tr("Clear Steps"))
380
+ self.btn_up = QPushButton(self.tr("▲ Move Up"))
381
+ self.btn_down = QPushButton(self.tr("▼ Move Down"))
382
382
 
383
- self.btn_drag_bundle = QPushButton("Drag Bundle")
384
- self.btn_run_active = QPushButton("Apply to Active View")
385
- self.btn_apply_to_vbundle = QPushButton("Apply to View Bundle…")
386
- self.btn_chip = QPushButton("Compress to Chip")
383
+ self.btn_drag_bundle = QPushButton(self.tr("Drag Bundle"))
384
+ self.btn_run_active = QPushButton(self.tr("Apply to Active View"))
385
+ self.btn_apply_to_vbundle = QPushButton(self.tr("Apply to View Bundle…"))
386
+ self.btn_chip = QPushButton(self.tr("Compress to Chip"))
387
387
 
388
388
  # layout
389
389
  left = QVBoxLayout()
390
- left.addWidget(QLabel("Function Bundles"))
390
+ left.addWidget(QLabel(self.tr("Function Bundles")))
391
391
  left.addWidget(self.list, 1)
392
392
  row = QHBoxLayout()
393
393
  row.addWidget(self.btn_new); row.addWidget(self.btn_dup); row.addWidget(self.btn_del)
@@ -129,7 +129,7 @@ from PyQt6.QtGui import (QPixmap, QColor, QIcon, QKeySequence, QShortcut,
129
129
 
130
130
  # ----- QtCore -----
131
131
  from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QTimer, QSize, QSignalBlocker, QModelIndex, QThread, QUrl, QSettings, QEvent, QByteArray, QObject,
132
- QPropertyAnimation, QEasingCurve
132
+ QPropertyAnimation, QEasingCurve, QElapsedTimer
133
133
  )
134
134
 
135
135
  from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
@@ -187,7 +187,7 @@ from setiastro.saspro.resources import (
187
187
  platesolve_path, psf_path, supernova_path, starregistration_path,
188
188
  stacking_path, pedestal_icon_path, starspike_path, aperture_path,
189
189
  jwstpupil_path, signature_icon_path, livestacking_path, hrdiagram_path,
190
- convoicon_path, spcc_icon_path, sasp_data_path, exoicon_path, peeker_icon,
190
+ convoicon_path, spcc_icon_path, sasp_data_path, exoicon_path, peeker_icon,rotatearbitrary_path,
191
191
  dse_icon_path, astrobin_filters_csv_path, isophote_path, statstretch_path,
192
192
  starstretch_path, curves_path, disk_path, uhs_path, blink_path, ppp_path,
193
193
  nbtorgb_path, freqsep_path, contsub_path, halo_path, cosmic_path,
@@ -310,7 +310,7 @@ class AstroSuiteProMainWindow(
310
310
  from setiastro.saspro.window_shelf import WindowShelf, MinimizeInterceptor
311
311
  from setiastro.saspro.imageops.mdi_snap import MdiSnapController
312
312
  from setiastro.saspro.ops.scripts import ScriptManager
313
- self._version = "1.6.1"
313
+ self._version = version
314
314
  self._build_timestamp = build_timestamp
315
315
  self.setWindowTitle(f"Seti Astro Suite Pro v{self._version}")
316
316
  self.resize(1400, 900)
@@ -467,26 +467,23 @@ class AstroSuiteProMainWindow(
467
467
  self.mdi.linkViewDropped.connect(self._on_linkview_drop)
468
468
 
469
469
  self.doc_manager.set_mdi_area(self.mdi)
470
-
470
+ # Coalesce undo/redo label refreshes
471
+ self._undo_redo_refresh_pending = False
472
+ self._undo_redo_refresh_timer = QTimer(self)
473
+ self._undo_redo_refresh_timer.setSingleShot(True)
474
+ self._undo_redo_refresh_timer.timeout.connect(self._do_undo_redo_label_refresh)
471
475
  # Keep the toolbar in sync whenever anything relevant changes
472
- self.doc_manager.documentAdded.connect(lambda *_: self.update_undo_redo_action_labels())
473
- self.doc_manager.documentRemoved.connect(lambda *_: self.update_undo_redo_action_labels())
474
- self.doc_manager.imageRegionUpdated.connect(lambda *_: self.update_undo_redo_action_labels())
475
- self.doc_manager.previewRepaintRequested.connect(lambda *_: self.update_undo_redo_action_labels())
476
-
477
- # Also refresh when the active subwindow changes
478
- try:
479
- self.mdi.subWindowActivated.connect(lambda *_: self.update_undo_redo_action_labels())
480
- except Exception:
481
- pass
482
-
483
- try:
484
- QApplication.instance().focusChanged.connect(
485
- lambda *_: QTimer.singleShot(0, self.update_undo_redo_action_labels)
486
- )
487
- except Exception:
488
- pass
489
-
476
+ self.doc_manager.documentAdded.connect(lambda *_: self._schedule_undo_redo_label_refresh())
477
+ self.doc_manager.documentRemoved.connect(lambda *_: self._schedule_undo_redo_label_refresh())
478
+ self.doc_manager.imageRegionUpdated.connect(lambda *_: self._schedule_undo_redo_label_refresh())
479
+ self.doc_manager.previewRepaintRequested.connect(lambda *_: self._schedule_undo_redo_label_refresh())
480
+ self.mdi.subWindowActivated.connect(lambda *_: self._schedule_undo_redo_label_refresh())
481
+
482
+ # optional: keep, but schedule (or remove entirely)
483
+ #try:
484
+ # QApplication.instance().focusChanged.connect(lambda *_: self._schedule_undo_redo_label_refresh())
485
+ #except Exception:
486
+ # pass
490
487
  self.shortcuts.load_shortcuts()
491
488
  self._ensure_persistent_names()
492
489
  self._restore_window_placement()
@@ -570,6 +567,22 @@ class AstroSuiteProMainWindow(
570
567
 
571
568
  # _init_log_dock, _hook_stdout_stderr, and _append_log_text are now in DockMixin
572
569
 
570
+ def _schedule_undo_redo_label_refresh(self):
571
+ # Coalesce many triggers into one UI update
572
+ if getattr(self, "_undo_redo_refresh_pending", False):
573
+ return
574
+ self._undo_redo_refresh_pending = True
575
+ # 0ms is fine *if* it’s a real attribute timer (not a local)
576
+ self._undo_redo_refresh_timer.start(0)
577
+
578
+ def _do_undo_redo_label_refresh(self):
579
+ self._undo_redo_refresh_pending = False
580
+ try:
581
+ self.update_undo_redo_action_labels()
582
+ except Exception:
583
+ pass
584
+
585
+
573
586
  def _rebuild_menus_for_language(self):
574
587
  """Rebuild menus after language change to apply new translations."""
575
588
  try:
@@ -608,7 +621,7 @@ class AstroSuiteProMainWindow(
608
621
  doc.changed.connect(self.update_undo_redo_action_labels)
609
622
  except Exception:
610
623
  pass
611
- self.update_undo_redo_action_labels()
624
+ self._schedule_undo_redo_label_refresh()
612
625
 
613
626
  def _promote_roi_preview_to_real_doc(self, st: dict, preview_doc) -> None:
614
627
  """
@@ -1206,11 +1219,11 @@ class AstroSuiteProMainWindow(
1206
1219
  global_pos = lw.viewport().mapToGlobal(pos)
1207
1220
 
1208
1221
  menu = QMenu(lw)
1209
- act_copy_selected = menu.addAction("Copy Selected")
1210
- act_copy_all = menu.addAction("Copy All")
1222
+ act_copy_selected = menu.addAction(self.tr("Copy Selected"))
1223
+ act_copy_all = menu.addAction(self.tr("Copy All"))
1211
1224
  menu.addSeparator()
1212
- act_select_all = menu.addAction("Select All Lines")
1213
- act_clear = menu.addAction("Clear Console")
1225
+ act_select_all = menu.addAction(self.tr("Select All Lines"))
1226
+ act_clear = menu.addAction(self.tr("Clear Console"))
1214
1227
 
1215
1228
  action = menu.exec(global_pos)
1216
1229
  if action is None:
@@ -1804,7 +1817,7 @@ class AstroSuiteProMainWindow(
1804
1817
 
1805
1818
  show_view_bundles(self)
1806
1819
  except Exception as e:
1807
- QMessageBox.warning(self, "View Bundles", f"Open failed:\n{e}")
1820
+ QMessageBox.warning(self, self.tr("View Bundles"), f"Open failed:\n{e}")
1808
1821
 
1809
1822
  def _open_function_bundles(self):
1810
1823
  from setiastro.saspro.function_bundle import show_function_bundles
@@ -1812,7 +1825,7 @@ class AstroSuiteProMainWindow(
1812
1825
 
1813
1826
  show_function_bundles(self)
1814
1827
  except Exception as e:
1815
- QMessageBox.warning(self, "Function Bundles", f"Open failed:\n{e}")
1828
+ QMessageBox.warning(self, self.tr("Function Bundles"), f"Open failed:\n{e}")
1816
1829
 
1817
1830
  def _open_scripts_folder(self):
1818
1831
  if hasattr(self, "scriptman"):
@@ -1874,43 +1887,43 @@ class AstroSuiteProMainWindow(
1874
1887
  # Manual list (extend anytime). Format: (Gesture, Context, Effect)
1875
1888
  rows = [
1876
1889
  # Command search
1877
- ("A", "Display Stretch", "Toggle Display Auto-Stretch"),
1878
- ("Ctrl+I", "Invert", "Invert the Image"),
1879
- ("Ctrl+Shift+P", "Command Search", "Focus the command search bar; Enter runs first match"),
1890
+ ("A", "Display Stretch", self.tr("Toggle Display Auto-Stretch")),
1891
+ ("Ctrl+I", "Invert", self.tr("Invert the Image")),
1892
+ ("Ctrl+Shift+P", "Command Search", self.tr("Focus the command search bar; Enter runs first match")),
1880
1893
 
1881
1894
  # View Icon
1882
- ("Drag view -> Off to Canvas", "View", "Duplicate Image"),
1883
- ("Drag view -> On to Other Image", "View", "Copy Zoom and Pan"),
1884
- ("Shift+Drag -> On to Other Image", "View", "Apply that image to the other as a mask"),
1885
- ("Ctrl+Drag -> On to Other Image", "View", "Copy Astrometric Solution"),
1895
+ ("Drag view -> Off to Canvas", "View", self.tr("Duplicate Image")),
1896
+ ("Drag view -> On to Other Image", "View", self.tr("Copy Zoom and Pan")),
1897
+ ("Shift+Drag -> On to Other Image", "View", self.tr("Apply that image to the other as a mask")),
1898
+ ("Ctrl+Drag -> On to Other Image", "View", self.tr("Copy Astrometric Solution")),
1886
1899
 
1887
1900
  # View zoom
1888
- ("Ctrl+1", "View", "Zoom to 100% (1:1)"),
1889
- ("Ctrl+0", "View", "Fit image to current window"),
1890
- ("Ctrl++", "View", "Zoom In"),
1891
- ("Ctrl+-", "View", "Zoom Out"),
1901
+ ("Ctrl+1", "View", self.tr("Zoom to 100% (1:1)")),
1902
+ ("Ctrl+0", "View", self.tr("Fit image to current window")),
1903
+ ("Ctrl++", "View", self.tr("Zoom In")),
1904
+ ("Ctrl+-", "View", self.tr("Zoom Out")),
1892
1905
 
1893
1906
  # Window switching
1894
- ("Ctrl+PgDown", "MDI", "Switch to previously active view"),
1895
- ("Ctrl+PgUp", "MDI", "Switch to next active view"),
1907
+ ("Ctrl+PgDown", "MDI", self.tr("Switch to previously active view")),
1908
+ ("Ctrl+PgUp", "MDI", self.tr("Switch to next active view")),
1896
1909
 
1897
1910
  # Shortcuts canvas + buttons
1898
- ("Alt+Drag (toolbar button)", "Toolbar", "Create a desktop shortcut for that action"),
1899
- ("Alt+Drag (shortcut button -> view)", "Shortcuts", "Headless apply the shortcut's command/preset to a view"),
1900
- ("Ctrl/Shift+Click", "Shortcuts", "Multi-select shortcut buttons"),
1901
- ("Drag (selection)", "Shortcuts", "Move selected shortcut buttons"),
1902
- ("Delete / Backspace", "Shortcuts", "Delete selected shortcut buttons"),
1903
- ("Ctrl+A", "Shortcuts", "Select all shortcut buttons"),
1904
- ("Double-click empty area", "MDI background", "Open files dialog"),
1911
+ ("Alt+Drag (toolbar button)", "Toolbar", self.tr("Create a desktop shortcut for that action")),
1912
+ ("Alt+Drag (shortcut button -> view)", "Shortcuts", self.tr("Headless apply the shortcut's command/preset to a view")),
1913
+ ("Ctrl/Shift+Click", "Shortcuts", self.tr("Multi-select shortcut buttons")),
1914
+ ("Drag (selection)", "Shortcuts", self.tr("Move selected shortcut buttons")),
1915
+ ("Delete / Backspace", "Shortcuts", self.tr("Delete selected shortcut buttons")),
1916
+ ("Ctrl+A", "Shortcuts", self.tr("Select all shortcut buttons")),
1917
+ ("Double-click empty area", "MDI background", self.tr("Open files dialog")),
1905
1918
 
1906
1919
  # Layers dock
1907
- ("Drag view -> Layers list", "Layers", "Add dragged view as a new layer (on top)"),
1908
- ("Shift+Drag mask -> Layers list", "Layers", "Attach dragged image as mask to the selected layer"),
1920
+ ("Drag view -> Layers list", "Layers", self.tr("Add dragged view as a new layer (on top)")),
1921
+ ("Shift+Drag mask -> Layers list", "Layers", self.tr("Attach dragged image as mask to the selected layer")),
1909
1922
 
1910
1923
  # Crop tool
1911
- ("Click-drag", "Crop Tool", "Draw a crop rectangle"),
1912
- ("Drag corner handles", "Crop Tool", "Resize crop rectangle"),
1913
- ("Shift+Drag on box", "Crop Tool", "Rotate crop rectangle"),
1924
+ ("Click-drag", "Crop Tool", self.tr("Draw a crop rectangle")),
1925
+ ("Drag corner handles", "Crop Tool", self.tr("Resize crop rectangle")),
1926
+ ("Shift+Drag on box", "Crop Tool", self.tr("Rotate crop rectangle")),
1914
1927
  ]
1915
1928
  return rows
1916
1929
 
@@ -2027,7 +2040,7 @@ class AstroSuiteProMainWindow(
2027
2040
  if getattr(self, "doc_manager", None) and self.doc_manager._docs:
2028
2041
  if not self._confirm_discard(
2029
2042
  title=title,
2030
- msg=(
2043
+ msg=self.tr(
2031
2044
  "Loading a project will close current views and replace desktop shortcuts.\n"
2032
2045
  "Continue?"
2033
2046
  ),
@@ -5542,6 +5555,10 @@ class AstroSuiteProMainWindow(
5542
5555
  "rotate_180": "geom_rotate_180",
5543
5556
  "geom_rotate_180": "geom_rotate_180",
5544
5557
 
5558
+ "rotate_any": "geom_rotate_any",
5559
+ "rotate_arbitrary": "geom_rotate_any",
5560
+ "geom_rotate_any": "geom_rotate_any",
5561
+
5545
5562
  "invert": "geom_invert",
5546
5563
  "geom_invert": "geom_invert",
5547
5564
 
@@ -6542,6 +6559,17 @@ class AstroSuiteProMainWindow(
6542
6559
  QMessageBox.warning(self, "Rotate 180Â deg", str(e))
6543
6560
  return
6544
6561
 
6562
+ if cid == "geom_rotate_any":
6563
+ try:
6564
+ angle = float(preset.get("angle_deg", preset.get("angle", 0.0)))
6565
+ called = _call_any(["_apply_geom_rot_any_to_doc"], doc, angle_deg=angle)
6566
+ if not called:
6567
+ raise RuntimeError("No rotate-any apply method found")
6568
+ self._log(f"Rotate ({angle:g}°) applied to '{target_sw.windowTitle()}'")
6569
+ except Exception as e:
6570
+ QMessageBox.warning(self, "Rotate...", str(e))
6571
+ return
6572
+
6545
6573
  if cid == "geom_rescale":
6546
6574
  try:
6547
6575
  factor = float(preset.get("factor", 1.0))
@@ -7347,7 +7375,7 @@ class AstroSuiteProMainWindow(
7347
7375
  self._search_dock = None
7348
7376
 
7349
7377
  # --- Right-side mini dock with the search box ---
7350
- self._search_dock = QDockWidget("Command Search", self)
7378
+ self._search_dock = QDockWidget(self.tr("Command Search"), self)
7351
7379
  self._search_dock.setObjectName("CommandSearchDock")
7352
7380
  # âœ... Allow moving/closing like other panels
7353
7381
  self._search_dock.setAllowedAreas(
@@ -7562,7 +7590,7 @@ class AstroSuiteProMainWindow(
7562
7590
  except Exception:
7563
7591
  pass
7564
7592
 
7565
- try: self.update_undo_redo_action_labels()
7593
+ try: self._schedule_undo_redo_label_refresh()
7566
7594
  except Exception as e:
7567
7595
  import logging
7568
7596
  logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
@@ -8050,7 +8078,7 @@ class AstroSuiteProMainWindow(
8050
8078
  # If no subwindows remain, clear all "active doc" UI bits, including header
8051
8079
  if not self.mdi.subWindowList():
8052
8080
  self.currentDocumentChanged.emit(None) # drives HeaderViewerDock.set_document(None)
8053
- self.update_undo_redo_action_labels()
8081
+ self._schedule_undo_redo_label_refresh()
8054
8082
  self._hdr_refresh_timer.start(0) # belt-and-suspenders for manual widgets
8055
8083
  # If your dock has its own set_document, call it explicitly too
8056
8084
  hv = getattr(self, "header_viewer", None)
@@ -8392,19 +8420,20 @@ class AstroSuiteProMainWindow(
8392
8420
 
8393
8421
  # Misc UI refreshes (guarded)
8394
8422
  try:
8395
- self.update_undo_redo_action_labels()
8396
- except Exception:
8397
- pass
8398
- try:
8399
- if hasattr(self, "_hdr_refresh_timer") and self._hdr_refresh_timer is not None:
8400
- self._hdr_refresh_timer.start(0)
8423
+ self._schedule_undo_redo_label_refresh()
8401
8424
  except Exception:
8402
8425
  pass
8426
+ #try:
8427
+ # if hasattr(self, "_hdr_refresh_timer") and self._hdr_refresh_timer is not None:
8428
+ # self._hdr_refresh_timer.start(0)
8429
+ #except Exception:
8430
+ # pass
8403
8431
  try:
8404
8432
  self._refresh_mask_action_states()
8405
8433
  except Exception:
8406
8434
  pass
8407
8435
 
8436
+
8408
8437
  def _sync_docman_active(self, doc):
8409
8438
  dm = self.doc_manager
8410
8439
  try:
@@ -243,22 +243,35 @@ class DockMixin:
243
243
  self.settings.setValue("ui/resource_monitor_visible", checked)
244
244
 
245
245
  def _update_monitor_position(self):
246
- """Snap monitor to bottom-right corner."""
246
+ """Snap monitor to bottom-right corner or restore saved position."""
247
247
  if hasattr(self, 'resource_monitor') and self.resource_monitor:
248
248
  from PyQt6.QtCore import QPoint
249
- m = 5 # margin
250
- # Position relative to the main window geometry
251
- w = self.resource_monitor.width()
252
- h = self.resource_monitor.height()
253
249
 
254
- # Anchor to bottom-right of the window
255
- x = self.width() - w - m
256
- y = self.height() - h - m
250
+ # Check for saved position first
251
+ saved_x = self.settings.value("ui/resource_monitor_pos_x", type=int)
252
+ saved_y = self.settings.value("ui/resource_monitor_pos_y", type=int)
257
253
 
258
- # Map local MainWindow coordinates to Global Screen coordinates
259
- # This is required because resource_monitor is a Top-Level Window (for transparency)
260
- global_pos = self.mapToGlobal(QPoint(x, y))
261
- self.resource_monitor.move(global_pos)
254
+ if saved_x != 0 and saved_y != 0: # Basic validity check (0,0 is unlikely to be desired but also default if missing)
255
+ # Actually 0,0 is valid but type=int returns 0 if missing.
256
+ # Let's check string existence to be safer or just accept 0 if set.
257
+ # Checking existence via `contains` is better but value() logic is ok for now.
258
+ if self.settings.contains("ui/resource_monitor_pos_x"):
259
+ self.resource_monitor.move(saved_x, saved_y)
260
+ self.resource_monitor.raise_()
261
+ return
262
+
263
+ m = 5 # margin
264
+
265
+ screen = self.screen()
266
+ geom = screen.availableGeometry()
267
+
268
+ mw = self.resource_monitor.width()
269
+ mh = self.resource_monitor.height()
270
+
271
+ x = geom.x() + geom.width() - mw - m
272
+ y = geom.y() + geom.height() - mh - m
273
+
274
+ self.resource_monitor.move(x, y)
262
275
  self.resource_monitor.raise_()
263
276
 
264
277
  # We need to hook resizeEvent to call _update_monitor_position.
@@ -290,12 +303,12 @@ class DockMixin:
290
303
 
291
304
  # Friendly ordering for common ones; others follow alphabetically.
292
305
  order_hint = {
293
- "Explorer": 10,
294
- "Console / Status": 20,
295
- "Header Viewer": 30,
296
- "Layers": 40,
297
- "Window Shelf": 50,
298
- "Command Search": 60,
306
+ self.tr("Explorer"): 10,
307
+ self.tr("Console / Status"): 20,
308
+ self.tr("Header Viewer"): 30,
309
+ self.tr("Layers"): 40,
310
+ self.tr("Window Shelf"): 50,
311
+ self.tr("Command Search"): 60,
299
312
  }
300
313
 
301
314
  # Add special action for overlay monitor
@@ -51,12 +51,10 @@ except ImportError:
51
51
  return cv2.resize(arr, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
52
52
 
53
53
 
54
- # Try to import WCS update function
55
- try:
56
- from setiastro.saspro.wcs_utils import update_wcs_after_crop
57
- except ImportError:
58
- update_wcs_after_crop = None
54
+ from setiastro.saspro.wcs_update import update_wcs_after_crop
59
55
 
56
+ import cv2
57
+ import math
60
58
 
61
59
  if TYPE_CHECKING:
62
60
  pass
@@ -209,6 +207,44 @@ class GeometryMixin:
209
207
  except Exception as e:
210
208
  QMessageBox.critical(self, "Rotate 180°", str(e))
211
209
 
210
+ def _exec_geom_rot_any(self):
211
+ sw = self.mdi.activeSubWindow() if hasattr(self, "mdi") else None
212
+ view = sw.widget() if sw else None
213
+ doc = getattr(view, "document", None)
214
+ if doc is None or getattr(doc, "image", None) is None:
215
+ QMessageBox.information(self, self.tr("Rotate..."), self.tr("Active view has no image."))
216
+ return
217
+
218
+ if cv2 is None:
219
+ QMessageBox.warning(self, self.tr("Rotate..."), self.tr("OpenCV (cv2) is required for arbitrary rotation."))
220
+ return
221
+
222
+ dlg = QInputDialog(self)
223
+ dlg.setWindowTitle(self.tr("Rotate..."))
224
+ dlg.setLabelText(self.tr("Angle in degrees (positive = CCW):"))
225
+ dlg.setInputMode(QInputDialog.InputMode.DoubleInput)
226
+ dlg.setDoubleRange(-360.0, 360.0)
227
+ dlg.setDoubleDecimals(2)
228
+ dlg.setDoubleValue(0.0)
229
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
230
+
231
+ try:
232
+ from setiastro.saspro.resources import rotatearbitrary_path
233
+ dlg.setWindowIcon(QIcon(rotatearbitrary_path))
234
+ except Exception:
235
+ pass
236
+
237
+ if dlg.exec() != QDialog.DialogCode.Accepted:
238
+ return
239
+
240
+ angle = float(dlg.doubleValue())
241
+ try:
242
+ self._apply_geom_rot_any_to_doc(doc, angle_deg=angle)
243
+ self._log(f"Rotate ({angle:g}°) applied to active view")
244
+ except Exception as e:
245
+ QMessageBox.critical(self, self.tr("Rotate..."), str(e))
246
+
247
+
212
248
  def _exec_geom_rescale(self):
213
249
  """Execute rescale operation on active view with dialog."""
214
250
  sw = self.mdi.activeSubWindow() if hasattr(self, "mdi") else None
@@ -334,6 +370,70 @@ class GeometryMixin:
334
370
 
335
371
  self._apply_geom_with_wcs(doc, out, M_src_to_dst=M, step_name="Rotate 180°")
336
372
 
373
+ def _apply_geom_rot_any_to_doc(self, doc, *, angle_deg: float):
374
+ if cv2 is None:
375
+ raise RuntimeError("cv2 is required for arbitrary rotation")
376
+
377
+ src = np.asarray(doc.image, dtype=np.float32, order="C")
378
+ h, w = src.shape[:2]
379
+
380
+ # Rotation about center
381
+ cx = (w - 1) * 0.5
382
+ cy = (h - 1) * 0.5
383
+
384
+ # OpenCV uses CCW degrees
385
+ A2 = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # 2x3
386
+
387
+ # Convert to 3x3
388
+ M = np.array([
389
+ [A2[0,0], A2[0,1], A2[0,2]],
390
+ [A2[1,0], A2[1,1], A2[1,2]],
391
+ [0.0, 0.0, 1.0 ],
392
+ ], dtype=np.float32)
393
+
394
+ # Compute output bounds by rotating the four corners
395
+ corners = np.array([
396
+ [0.0, 0.0, 1.0],
397
+ [w - 1.0, 0.0, 1.0],
398
+ [w - 1.0, h - 1.0, 1.0],
399
+ [0.0, h - 1.0, 1.0],
400
+ ], dtype=np.float32).T # 3x4
401
+
402
+ rc = (M @ corners) # 3x4
403
+ xs = rc[0, :]
404
+ ys = rc[1, :]
405
+
406
+ min_x = float(xs.min())
407
+ max_x = float(xs.max())
408
+ min_y = float(ys.min())
409
+ max_y = float(ys.max())
410
+
411
+ out_w = int(math.ceil(max_x - min_x + 1.0))
412
+ out_h = int(math.ceil(max_y - min_y + 1.0))
413
+ if out_w <= 0 or out_h <= 0:
414
+ raise RuntimeError("Invalid output size after rotation")
415
+
416
+ # Shift so that min corner maps to (0,0)
417
+ T = np.array([
418
+ [1.0, 0.0, -min_x],
419
+ [0.0, 1.0, -min_y],
420
+ [0.0, 0.0, 1.0],
421
+ ], dtype=np.float32)
422
+
423
+ M = (T @ M).astype(np.float32) # final src->dst 3x3
424
+
425
+ # Warp
426
+ # cv2.warpPerspective expects (W,H)
427
+ flags = cv2.INTER_LANCZOS4
428
+ if src.ndim == 2:
429
+ out = cv2.warpPerspective(src, M, (out_w, out_h), flags=flags)
430
+ else:
431
+ # warpPerspective works on multi-channel too
432
+ out = cv2.warpPerspective(src, M, (out_w, out_h), flags=flags)
433
+
434
+ self._apply_geom_with_wcs(doc, out, M_src_to_dst=M, step_name=f"Rotate ({angle_deg:g}°)")
435
+
436
+
337
437
  def _apply_geom_rescale_to_doc(self, doc, *, factor: float):
338
438
  """Apply rescale to document with WCS update."""
339
439
  factor = float(max(0.1, min(10.0, factor)))
@@ -185,6 +185,7 @@ class MenuMixin:
185
185
  m_geom.addAction(self.act_geom_rot_cw)
186
186
  m_geom.addAction(self.act_geom_rot_ccw)
187
187
  m_geom.addAction(self.act_geom_rot_180)
188
+ m_geom.addAction(self.act_geom_rot_any)
188
189
  m_geom.addSeparator()
189
190
  m_geom.addAction(self.act_geom_rescale)
190
191
  m_geom.addSeparator()