setiastrosuitepro 1.6.0__py3-none-any.whl → 1.6.4.post1__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 (293) hide show
  1. setiastro/data/SASP_data.fits +0 -0
  2. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  3. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  4. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  5. setiastro/data/catalogs/cali2.csv +63 -0
  6. setiastro/data/catalogs/cali2color.csv +65 -0
  7. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  8. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  9. setiastro/data/catalogs/detected_stars.csv +24784 -0
  10. setiastro/data/catalogs/fits_header_data.csv +46 -0
  11. setiastro/data/catalogs/test.csv +8 -0
  12. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  13. setiastro/images/Astro_Spikes.png +0 -0
  14. setiastro/images/Background_startup.jpg +0 -0
  15. setiastro/images/HRDiagram.png +0 -0
  16. setiastro/images/LExtract.png +0 -0
  17. setiastro/images/LInsert.png +0 -0
  18. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  19. setiastro/images/RGB080604.png +0 -0
  20. setiastro/images/abeicon.png +0 -0
  21. setiastro/images/aberration.png +0 -0
  22. setiastro/images/andromedatry.png +0 -0
  23. setiastro/images/andromedatry_satellited.png +0 -0
  24. setiastro/images/annotated.png +0 -0
  25. setiastro/images/aperture.png +0 -0
  26. setiastro/images/astrosuite.ico +0 -0
  27. setiastro/images/astrosuite.png +0 -0
  28. setiastro/images/astrosuitepro.icns +0 -0
  29. setiastro/images/astrosuitepro.ico +0 -0
  30. setiastro/images/astrosuitepro.png +0 -0
  31. setiastro/images/background.png +0 -0
  32. setiastro/images/background2.png +0 -0
  33. setiastro/images/benchmark.png +0 -0
  34. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  36. setiastro/images/blaster.png +0 -0
  37. setiastro/images/blink.png +0 -0
  38. setiastro/images/clahe.png +0 -0
  39. setiastro/images/collage.png +0 -0
  40. setiastro/images/colorwheel.png +0 -0
  41. setiastro/images/contsub.png +0 -0
  42. setiastro/images/convo.png +0 -0
  43. setiastro/images/copyslot.png +0 -0
  44. setiastro/images/cosmic.png +0 -0
  45. setiastro/images/cosmicsat.png +0 -0
  46. setiastro/images/crop1.png +0 -0
  47. setiastro/images/cropicon.png +0 -0
  48. setiastro/images/curves.png +0 -0
  49. setiastro/images/cvs.png +0 -0
  50. setiastro/images/debayer.png +0 -0
  51. setiastro/images/denoise_cnn_custom.png +0 -0
  52. setiastro/images/denoise_cnn_graph.png +0 -0
  53. setiastro/images/disk.png +0 -0
  54. setiastro/images/dse.png +0 -0
  55. setiastro/images/exoicon.png +0 -0
  56. setiastro/images/eye.png +0 -0
  57. setiastro/images/fliphorizontal.png +0 -0
  58. setiastro/images/flipvertical.png +0 -0
  59. setiastro/images/font.png +0 -0
  60. setiastro/images/freqsep.png +0 -0
  61. setiastro/images/functionbundle.png +0 -0
  62. setiastro/images/graxpert.png +0 -0
  63. setiastro/images/green.png +0 -0
  64. setiastro/images/gridicon.png +0 -0
  65. setiastro/images/halo.png +0 -0
  66. setiastro/images/hdr.png +0 -0
  67. setiastro/images/histogram.png +0 -0
  68. setiastro/images/hubble.png +0 -0
  69. setiastro/images/imagecombine.png +0 -0
  70. setiastro/images/invert.png +0 -0
  71. setiastro/images/isophote.png +0 -0
  72. setiastro/images/isophote_demo_figure.png +0 -0
  73. setiastro/images/isophote_demo_image.png +0 -0
  74. setiastro/images/isophote_demo_model.png +0 -0
  75. setiastro/images/isophote_demo_residual.png +0 -0
  76. setiastro/images/jwstpupil.png +0 -0
  77. setiastro/images/linearfit.png +0 -0
  78. setiastro/images/livestacking.png +0 -0
  79. setiastro/images/mask.png +0 -0
  80. setiastro/images/maskapply.png +0 -0
  81. setiastro/images/maskcreate.png +0 -0
  82. setiastro/images/maskremove.png +0 -0
  83. setiastro/images/morpho.png +0 -0
  84. setiastro/images/mosaic.png +0 -0
  85. setiastro/images/multiscale_decomp.png +0 -0
  86. setiastro/images/nbtorgb.png +0 -0
  87. setiastro/images/neutral.png +0 -0
  88. setiastro/images/nuke.png +0 -0
  89. setiastro/images/openfile.png +0 -0
  90. setiastro/images/pedestal.png +0 -0
  91. setiastro/images/pen.png +0 -0
  92. setiastro/images/pixelmath.png +0 -0
  93. setiastro/images/platesolve.png +0 -0
  94. setiastro/images/ppp.png +0 -0
  95. setiastro/images/pro.png +0 -0
  96. setiastro/images/project.png +0 -0
  97. setiastro/images/psf.png +0 -0
  98. setiastro/images/redo.png +0 -0
  99. setiastro/images/redoicon.png +0 -0
  100. setiastro/images/rescale.png +0 -0
  101. setiastro/images/rgbalign.png +0 -0
  102. setiastro/images/rgbcombo.png +0 -0
  103. setiastro/images/rgbextract.png +0 -0
  104. setiastro/images/rotate180.png +0 -0
  105. setiastro/images/rotatearbitrary.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__main__.py +228 -67
  146. setiastro/saspro/_generated/build_info.py +2 -1
  147. setiastro/saspro/abe.py +76 -25
  148. setiastro/saspro/aberration_ai.py +14 -14
  149. setiastro/saspro/add_stars.py +15 -12
  150. setiastro/saspro/astrobin_exporter.py +61 -58
  151. setiastro/saspro/astrospike_python.py +3 -1
  152. setiastro/saspro/autostretch.py +4 -2
  153. setiastro/saspro/backgroundneutral.py +65 -14
  154. setiastro/saspro/batch_convert.py +8 -5
  155. setiastro/saspro/batch_renamer.py +39 -36
  156. setiastro/saspro/blemish_blaster.py +15 -12
  157. setiastro/saspro/blink_comparator_pro.py +605 -379
  158. setiastro/saspro/cheat_sheet.py +62 -17
  159. setiastro/saspro/clahe.py +34 -8
  160. setiastro/saspro/comet_stacking.py +103 -38
  161. setiastro/saspro/common_tr.py +107 -0
  162. setiastro/saspro/continuum_subtract.py +7 -7
  163. setiastro/saspro/convo.py +12 -9
  164. setiastro/saspro/copyastro.py +3 -0
  165. setiastro/saspro/cosmicclarity.py +77 -52
  166. setiastro/saspro/crop_dialog_pro.py +80 -45
  167. setiastro/saspro/curve_editor_pro.py +51 -33
  168. setiastro/saspro/debayer.py +6 -3
  169. setiastro/saspro/doc_manager.py +49 -19
  170. setiastro/saspro/exoplanet_detector.py +11 -11
  171. setiastro/saspro/fitsmodifier.py +48 -44
  172. setiastro/saspro/fix_bom.py +32 -0
  173. setiastro/saspro/frequency_separation.py +18 -12
  174. setiastro/saspro/function_bundle.py +18 -16
  175. setiastro/saspro/generate_translations.py +3092 -0
  176. setiastro/saspro/ghs_dialog_pro.py +19 -16
  177. setiastro/saspro/graxpert.py +3 -0
  178. setiastro/saspro/gui/main_window.py +471 -126
  179. setiastro/saspro/gui/mixins/dock_mixin.py +123 -11
  180. setiastro/saspro/gui/mixins/file_mixin.py +25 -20
  181. setiastro/saspro/gui/mixins/geometry_mixin.py +115 -15
  182. setiastro/saspro/gui/mixins/header_mixin.py +6 -6
  183. setiastro/saspro/gui/mixins/mask_mixin.py +8 -8
  184. setiastro/saspro/gui/mixins/menu_mixin.py +62 -33
  185. setiastro/saspro/gui/mixins/toolbar_mixin.py +382 -226
  186. setiastro/saspro/gui/mixins/update_mixin.py +26 -26
  187. setiastro/saspro/gui/statistics_dialog.py +47 -0
  188. setiastro/saspro/halobgon.py +29 -3
  189. setiastro/saspro/header_viewer.py +21 -18
  190. setiastro/saspro/histogram.py +29 -26
  191. setiastro/saspro/history_explorer.py +2 -0
  192. setiastro/saspro/i18n.py +168 -0
  193. setiastro/saspro/image_combine.py +3 -0
  194. setiastro/saspro/image_peeker_pro.py +52 -44
  195. setiastro/saspro/imageops/stretch.py +5 -13
  196. setiastro/saspro/isophote.py +3 -0
  197. setiastro/saspro/legacy/numba_utils.py +64 -47
  198. setiastro/saspro/linear_fit.py +3 -0
  199. setiastro/saspro/live_stacking.py +13 -2
  200. setiastro/saspro/mask_creation.py +180 -22
  201. setiastro/saspro/mfdeconv.py +5 -0
  202. setiastro/saspro/morphology.py +38 -13
  203. setiastro/saspro/multiscale_decomp.py +713 -256
  204. setiastro/saspro/nbtorgb_stars.py +12 -2
  205. setiastro/saspro/numba_utils.py +149 -48
  206. setiastro/saspro/ops/scripts.py +77 -17
  207. setiastro/saspro/ops/settings.py +177 -100
  208. setiastro/saspro/perfect_palette_picker.py +25 -7
  209. setiastro/saspro/pixelmath.py +114 -110
  210. setiastro/saspro/plate_solver.py +118 -108
  211. setiastro/saspro/remove_green.py +24 -7
  212. setiastro/saspro/remove_stars.py +136 -162
  213. setiastro/saspro/remove_stars_preset.py +55 -13
  214. setiastro/saspro/resources.py +46 -15
  215. setiastro/saspro/rgb_combination.py +19 -18
  216. setiastro/saspro/rgbalign.py +11 -11
  217. setiastro/saspro/save_options.py +5 -4
  218. setiastro/saspro/selective_color.py +84 -25
  219. setiastro/saspro/sfcc.py +119 -72
  220. setiastro/saspro/shortcuts.py +345 -36
  221. setiastro/saspro/signature_insert.py +4 -1
  222. setiastro/saspro/stacking_suite.py +2066 -1119
  223. setiastro/saspro/star_alignment.py +291 -331
  224. setiastro/saspro/star_spikes.py +137 -53
  225. setiastro/saspro/star_stretch.py +47 -10
  226. setiastro/saspro/stat_stretch.py +52 -16
  227. setiastro/saspro/status_log_dock.py +1 -1
  228. setiastro/saspro/subwindow.py +97 -36
  229. setiastro/saspro/supernovaasteroidhunter.py +68 -61
  230. setiastro/saspro/swap_manager.py +77 -42
  231. setiastro/saspro/translations/all_source_strings.json +4726 -0
  232. setiastro/saspro/translations/ar_translations.py +4096 -0
  233. setiastro/saspro/translations/de_translations.py +3728 -0
  234. setiastro/saspro/translations/es_translations.py +4169 -0
  235. setiastro/saspro/translations/fr_translations.py +4090 -0
  236. setiastro/saspro/translations/hi_translations.py +3803 -0
  237. setiastro/saspro/translations/integrate_translations.py +271 -0
  238. setiastro/saspro/translations/it_translations.py +4728 -0
  239. setiastro/saspro/translations/ja_translations.py +3834 -0
  240. setiastro/saspro/translations/pt_translations.py +3847 -0
  241. setiastro/saspro/translations/ru_translations.py +3082 -0
  242. setiastro/saspro/translations/saspro_ar.qm +0 -0
  243. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  244. setiastro/saspro/translations/saspro_de.qm +0 -0
  245. setiastro/saspro/translations/saspro_de.ts +14548 -0
  246. setiastro/saspro/translations/saspro_es.qm +0 -0
  247. setiastro/saspro/translations/saspro_es.ts +16202 -0
  248. setiastro/saspro/translations/saspro_fr.qm +0 -0
  249. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  250. setiastro/saspro/translations/saspro_hi.qm +0 -0
  251. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  252. setiastro/saspro/translations/saspro_it.qm +0 -0
  253. setiastro/saspro/translations/saspro_it.ts +19046 -0
  254. setiastro/saspro/translations/saspro_ja.qm +0 -0
  255. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  256. setiastro/saspro/translations/saspro_pt.qm +0 -0
  257. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  258. setiastro/saspro/translations/saspro_ru.qm +0 -0
  259. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  260. setiastro/saspro/translations/saspro_sw.qm +0 -0
  261. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  262. setiastro/saspro/translations/saspro_uk.qm +0 -0
  263. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  264. setiastro/saspro/translations/saspro_zh.qm +0 -0
  265. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  266. setiastro/saspro/translations/sw_translations.py +3897 -0
  267. setiastro/saspro/translations/uk_translations.py +3929 -0
  268. setiastro/saspro/translations/zh_translations.py +3910 -0
  269. setiastro/saspro/versioning.py +77 -0
  270. setiastro/saspro/view_bundle.py +20 -17
  271. setiastro/saspro/wavescale_hdr.py +54 -33
  272. setiastro/saspro/wavescale_hdr_preset.py +6 -5
  273. setiastro/saspro/wavescalede.py +54 -31
  274. setiastro/saspro/wavescalede_preset.py +9 -7
  275. setiastro/saspro/whitebalance.py +58 -22
  276. setiastro/saspro/widgets/common_utilities.py +12 -11
  277. setiastro/saspro/widgets/minigame/game.js +991 -0
  278. setiastro/saspro/widgets/minigame/index.html +53 -0
  279. setiastro/saspro/widgets/minigame/style.css +241 -0
  280. setiastro/saspro/widgets/preview_dialogs.py +8 -8
  281. setiastro/saspro/widgets/resource_monitor.py +263 -0
  282. setiastro/saspro/widgets/spinboxes.py +18 -0
  283. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  284. setiastro/saspro/wimi.py +7996 -0
  285. setiastro/saspro/wims.py +578 -0
  286. setiastro/saspro/window_shelf.py +2 -2
  287. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/METADATA +15 -3
  288. setiastrosuitepro-1.6.4.post1.dist-info/RECORD +368 -0
  289. setiastrosuitepro-1.6.0.dist-info/RECORD +0 -174
  290. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/WHEEL +0 -0
  291. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/entry_points.txt +0 -0
  292. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/licenses/LICENSE +0 -0
  293. {setiastrosuitepro-1.6.0.dist-info → setiastrosuitepro-1.6.4.post1.dist-info}/licenses/license.txt +0 -0
@@ -14,7 +14,7 @@ from PyQt6.QtWidgets import (
14
14
  QVBoxLayout, QWidget, QTextEdit, QListWidget, QListWidgetItem,
15
15
  QAbstractItemView, QApplication
16
16
  )
17
- from PyQt6.QtGui import QTextCursor
17
+ from PyQt6.QtGui import QTextCursor, QAction
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from PyQt6.QtWidgets import QAction
@@ -30,7 +30,7 @@ class DockMixin:
30
30
 
31
31
  def _init_log_dock(self):
32
32
  """Initialize the system log dock widget."""
33
- self.log_dock = QDockWidget("System Log", self)
33
+ self.log_dock = QDockWidget(self.tr("System Log"), self)
34
34
  self.log_dock.setObjectName("LogDock")
35
35
  self.log_dock.setAllowedAreas(
36
36
  Qt.DockWidgetArea.BottomDockWidgetArea
@@ -45,7 +45,7 @@ class DockMixin:
45
45
  self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.log_dock)
46
46
 
47
47
  self.act_toggle_log = self.log_dock.toggleViewAction()
48
- self.act_toggle_log.setText("Show System Log Panel")
48
+ self.act_toggle_log.setText(self.tr("Show System Log Panel"))
49
49
 
50
50
  def _append_log_text(self, text: str):
51
51
  """Append text to the system log dock."""
@@ -111,7 +111,7 @@ class DockMixin:
111
111
  # Double-click: same behavior
112
112
  self.explorer.itemDoubleClicked.connect(self._activate_or_open_from_explorer)
113
113
 
114
- dock = QDockWidget("Explorer", self)
114
+ dock = QDockWidget(self.tr("Explorer"), self)
115
115
  dock.setWidget(self.explorer)
116
116
  dock.setObjectName("ExplorerDock")
117
117
  self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock)
@@ -126,7 +126,7 @@ class DockMixin:
126
126
  self.console.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
127
127
  self.console.customContextMenuRequested.connect(self._on_console_context_menu)
128
128
 
129
- dock = QDockWidget("Console / Status", self)
129
+ dock = QDockWidget(self.tr("Console / Status"), self)
130
130
  dock.setWidget(self.console)
131
131
  dock.setObjectName("ConsoleDock")
132
132
  self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, dock)
@@ -194,6 +194,99 @@ class DockMixin:
194
194
  except Exception:
195
195
  pass
196
196
 
197
+ def _init_resource_monitor_overlay(self):
198
+ """Initialize the QML System Resource Monitor as a floating overlay."""
199
+ try:
200
+ from setiastro.saspro.widgets.resource_monitor import SystemMonitorWidget
201
+
202
+ # Create as a child of the central widget or self to sit on top
203
+ # Using self (QMainWindow) allows it to float over everything including status bar if we want,
204
+ # but usually we want it over MDI area. Let's try self first for "floating" feel.
205
+ self.resource_monitor = SystemMonitorWidget(self)
206
+ self.resource_monitor.setObjectName("ResourceMonitorOverlay")
207
+
208
+ # Make it a proper independent window to allow true transparency (translucent background)
209
+ # without black artifacts from parent composition.
210
+ # Fixed: Removed WindowStaysOnTopHint to allow it to be obscured by other apps (Alt-Tab support)
211
+ self.resource_monitor.setWindowFlags(
212
+ Qt.WindowType.Window |
213
+ Qt.WindowType.FramelessWindowHint |
214
+ Qt.WindowType.Tool
215
+ )
216
+
217
+ # Sizing and Transparency
218
+ self.resource_monitor.setFixedSize(200, 60)
219
+ # self.resource_monitor.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) # Optional: if we want click-through
220
+
221
+
222
+ # Initial placement (will be updated by resizeEvent)
223
+ self._update_monitor_position()
224
+
225
+ # Defer visibility to MainWindow.showEvent to prevent appearing before main window
226
+ # visible = self.settings.value("ui/resource_monitor_visible", True, type=bool)
227
+ # if visible:
228
+ # self.resource_monitor.show()
229
+ # else:
230
+ # self.resource_monitor.hide()
231
+ except Exception as e:
232
+ print(f"WARNING: Could not initialize System Monitor overlay: {e}")
233
+ self.resource_monitor = None
234
+
235
+ def _toggle_resource_monitor(self, checked: bool):
236
+ """Toggle floating monitor visibility."""
237
+ if hasattr(self, 'resource_monitor') and self.resource_monitor:
238
+ if checked:
239
+ self.resource_monitor.show()
240
+ self._update_monitor_position()
241
+ else:
242
+ self.resource_monitor.hide()
243
+ self.settings.setValue("ui/resource_monitor_visible", checked)
244
+
245
+ def _update_monitor_position(self):
246
+ """Snap monitor to bottom-right corner or restore saved position."""
247
+ if hasattr(self, 'resource_monitor') and self.resource_monitor:
248
+ from PyQt6.QtCore import QPoint
249
+
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)
253
+
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)
275
+ self.resource_monitor.raise_()
276
+
277
+ # We need to hook resizeEvent to call _update_monitor_position.
278
+ # Since this is a mixin, we can't easily override resizeEvent of the MainWindow without being careful.
279
+ # Best way: install an event filter on self, or since we are a mixin mixed into MainWindow,
280
+ # we can rely on MainWindow calling a specific method or we can patch it...
281
+ # Actually, MainWindow likely has resizeEvent.
282
+ # simpler: QTimer check? No.
283
+ # Correct way for Mixin: The MainWindow class should call something.
284
+ # BUT, I can just installEventFilter(self) ? No, infinite loop risk.
285
+ #
286
+ # Let's use the 'GeometryMixin' or just add a standard method `_on_resize_for_monitor`
287
+ # and assume I can hook it in MainWindow.py.
288
+
289
+
197
290
  # ❌ Remove this old line; it let random mouse-over updates hijack the dock:
198
291
  # self.currentDocumentChanged.disconnect(self.header_viewer.set_document) # if previously connected
199
292
  # (If you prefer to keep the signal for explicit tab switches, it's fine to leave
@@ -210,13 +303,28 @@ class DockMixin:
210
303
 
211
304
  # Friendly ordering for common ones; others follow alphabetically.
212
305
  order_hint = {
213
- "Explorer": 10,
214
- "Console / Status": 20,
215
- "Header Viewer": 30,
216
- "Layers": 40,
217
- "Window Shelf": 50,
218
- "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,
219
312
  }
313
+
314
+ # Add special action for overlay monitor
315
+ mon_act = QAction(self.tr("System Monitor"), self)
316
+ mon_act.setCheckable(True)
317
+ mon_act.setChecked(self.settings.value("ui/resource_monitor_visible", True, type=bool))
318
+ mon_act.triggered.connect(self._toggle_resource_monitor)
319
+
320
+ # We need to insert it into the logic that populates the menu.
321
+ # But 'dock_mixin' automates menu from self.findChildren(QDockWidget).
322
+ # So we have to manually inject this action into the "Panels" menu if possible
323
+ # or expose it such that main_window can add it.
324
+ #
325
+ # Easier: allow main_window to add it, or ...
326
+ # If I can't easily see where menu is built, I'll bind it to self.act_toggle_monitor = mon_act
327
+ self.act_toggle_monitor = mon_act
220
328
 
221
329
  def key_fn(d: QDockWidget):
222
330
  t = d.windowTitle()
@@ -224,6 +332,10 @@ class DockMixin:
224
332
 
225
333
  for dock in sorted(docks, key=key_fn):
226
334
  self._register_dock_in_view_menu(dock)
335
+
336
+ if hasattr(self, "act_toggle_monitor"):
337
+ menu.addSeparator()
338
+ menu.addAction(self.act_toggle_monitor)
227
339
 
228
340
  def _add_doc_to_explorer(self, doc):
229
341
  base = self._normalize_base_doc(doc)
@@ -104,7 +104,7 @@ class FileMixin:
104
104
  if last_dir and not os.path.isdir(last_dir):
105
105
  last_dir = ""
106
106
 
107
- paths, _ = QFileDialog.getOpenFileNames(self, "Open Images", last_dir, filters)
107
+ paths, _ = QFileDialog.getOpenFileNames(self, self.tr("Open Images"), last_dir, filters)
108
108
  if not paths:
109
109
  return
110
110
 
@@ -120,8 +120,15 @@ class FileMixin:
120
120
  doc = self.docman.open_path(p) # this emits documentAdded
121
121
  self._log(f"Opened: {p}")
122
122
  self._add_recent_image(p) # âœ... track in MRU
123
+
124
+ # Increment statistics
125
+ try:
126
+ count = self.settings.value("stats/opened_images_count", 0, type=int)
127
+ self.settings.setValue("stats/opened_images_count", count + 1)
128
+ except Exception:
129
+ pass
123
130
  except Exception as e:
124
- QMessageBox.warning(self, "Open failed", f"{p}\n\n{e}")
131
+ QMessageBox.warning(self, self.tr("Open failed"), f"{p}\n\n{e}")
125
132
 
126
133
  def save_active(self):
127
134
  from setiastro.saspro.main_helpers import (
@@ -176,7 +183,7 @@ class FileMixin:
176
183
  suggested_path = os.path.join(candidate_dir, suggested_safe)
177
184
 
178
185
  # --- Open dialog ----------------------------------------
179
- path, selected_filter = QFileDialog.getSaveFileName(self, "Save As", suggested_path, filters)
186
+ path, selected_filter = QFileDialog.getSaveFileName(self, self.tr("Save As"), suggested_path, filters)
180
187
  if not path:
181
188
  return
182
189
 
@@ -201,7 +208,7 @@ class FileMixin:
201
208
  self._log(f"Saved: {path} ({chosen_bd})")
202
209
  self.settings.setValue("paths/last_save_dir", os.path.dirname(path))
203
210
  except Exception as e:
204
- QMessageBox.critical(self, "Save failed", str(e))
211
+ QMessageBox.critical(self, self.tr("Save failed"), str(e))
205
212
 
206
213
  def _load_recent_lists(self):
207
214
  """Load MRU lists from QSettings."""
@@ -251,9 +258,8 @@ class FileMixin:
251
258
  if not os.path.exists(path):
252
259
  if QMessageBox.question(
253
260
  self,
254
- "File not found",
255
- f"The file does not exist:\n{path}\n\n"
256
- "Remove it from the recent images list?",
261
+ self.tr("File not found"),
262
+ self.tr("The file does not exist:\n{path}\n\nRemove it from the recent images list?").replace("{path}", path),
257
263
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
258
264
  ) == QMessageBox.StandardButton.Yes:
259
265
  self._recent_image_paths = [p for p in self._recent_image_paths if p != path]
@@ -267,7 +273,7 @@ class FileMixin:
267
273
  # bump to front
268
274
  self._add_recent_image(path)
269
275
  except Exception as e:
270
- QMessageBox.warning(self, "Open failed", f"{path}\n\n{e}")
276
+ QMessageBox.warning(self, self.tr("Open failed"), f"{path}\n\n{e}")
271
277
 
272
278
  def _open_recent_project(self, path: str):
273
279
  if not path:
@@ -275,9 +281,8 @@ class FileMixin:
275
281
  if not os.path.exists(path):
276
282
  if QMessageBox.question(
277
283
  self,
278
- "Project not found",
279
- f"The project file does not exist:\n{path}\n\n"
280
- "Remove it from the recent projects list?",
284
+ self.tr("Project not found"),
285
+ self.tr("The project file does not exist:\n{path}\n\nRemove it from the recent projects list?").replace("{path}", path),
281
286
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
282
287
  ) == QMessageBox.StandardButton.Yes:
283
288
  self._recent_project_paths = [p for p in self._recent_project_paths if p != path]
@@ -310,7 +315,7 @@ class FileMixin:
310
315
 
311
316
  def _save_project(self):
312
317
  path, _ = QFileDialog.getSaveFileName(
313
- self, "Save Project", "", "SetiAstro Project (*.sas)"
318
+ self, self.tr("Save Project"), "", "SetiAstro Project (*.sas)"
314
319
  )
315
320
  if not path:
316
321
  return
@@ -319,15 +324,15 @@ class FileMixin:
319
324
 
320
325
  docs = self._collect_open_documents()
321
326
  if not docs:
322
- QMessageBox.warning(self, "Save Project", "No documents to save.")
327
+ QMessageBox.warning(self, self.tr("Save Project"), self.tr("No documents to save."))
323
328
  return
324
329
 
325
330
  try:
326
331
  compress = self._ask_project_compress() # your existing yes/no dialog
327
332
 
328
333
  # Busy dialog (indeterminate)
329
- dlg = QProgressDialog("Saving project...", "", 0, 0, self)
330
- dlg.setWindowTitle("Saving")
334
+ dlg = QProgressDialog(self.tr("Saving project..."), "", 0, 0, self)
335
+ dlg.setWindowTitle(self.tr("Saving"))
331
336
  # PyQt6 (with PyQt5 fallback if you ever run it there)
332
337
  try:
333
338
  dlg.setWindowModality(Qt.WindowModality.ApplicationModal)
@@ -365,7 +370,7 @@ class FileMixin:
365
370
  self._proj_save_worker.error.connect(
366
371
  lambda msg: (
367
372
  dlg.close(),
368
- QMessageBox.critical(self, "Save Project", f"Failed to save:\n{msg}"),
373
+ QMessageBox.critical(self, self.tr("Save Project"), self.tr("Failed to save:\n{msg}").replace("{msg}", msg)),
369
374
  )
370
375
  )
371
376
  self._proj_save_worker.finished.connect(
@@ -374,7 +379,7 @@ class FileMixin:
374
379
  self._proj_save_worker.start()
375
380
 
376
381
  except Exception as e:
377
- QMessageBox.critical(self, "Save Project", f"Failed to save:\n{e}")
382
+ QMessageBox.critical(self, self.tr("Save Project"), self.tr("Failed to save:\n{e}").replace("{e}", str(e)))
378
383
 
379
384
  def _load_project(self):
380
385
  # warn / clear current desktop
@@ -382,7 +387,7 @@ class FileMixin:
382
387
  return
383
388
 
384
389
  path, _ = QFileDialog.getOpenFileName(
385
- self, "Load Project", "", "SetiAstro Project (*.sas)"
390
+ self, self.tr("Load Project"), "", "SetiAstro Project (*.sas)"
386
391
  )
387
392
  if not path:
388
393
  return
@@ -390,8 +395,8 @@ class FileMixin:
390
395
  self._do_load_project_path(path)
391
396
 
392
397
  def _new_project(self):
393
- if not self._confirm_discard(title="New Project",
394
- msg="Start a new project? This closes all views and clears desktop shortcuts."):
398
+ if not self._confirm_discard(title=self.tr("New Project"),
399
+ msg=self.tr("Start a new project? This closes all views and clears desktop shortcuts.")):
395
400
  return
396
401
 
397
402
  # Close views + docs + shelf
@@ -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
@@ -131,7 +129,7 @@ class GeometryMixin:
131
129
  view = sw.widget() if sw else None
132
130
  doc = getattr(view, "document", None)
133
131
  if doc is None or getattr(doc, "image", None) is None:
134
- QMessageBox.information(self, "Invert", "Active view has no image.")
132
+ QMessageBox.information(self, self.tr("Invert"), self.tr("Active view has no image."))
135
133
  return
136
134
  try:
137
135
  self._apply_geom_invert_to_doc(doc)
@@ -145,7 +143,7 @@ class GeometryMixin:
145
143
  view = sw.widget() if sw else None
146
144
  doc = getattr(view, "document", None)
147
145
  if doc is None or getattr(doc, "image", None) is None:
148
- QMessageBox.information(self, "Flip Horizontal", "Active view has no image.")
146
+ QMessageBox.information(self, self.tr("Flip Horizontal"), self.tr("Active view has no image."))
149
147
  return
150
148
  try:
151
149
  self._apply_geom_flip_h_to_doc(doc)
@@ -159,7 +157,7 @@ class GeometryMixin:
159
157
  view = sw.widget() if sw else None
160
158
  doc = getattr(view, "document", None)
161
159
  if doc is None or getattr(doc, "image", None) is None:
162
- QMessageBox.information(self, "Flip Vertical", "Active view has no image.")
160
+ QMessageBox.information(self, self.tr("Flip Vertical"), self.tr("Active view has no image."))
163
161
  return
164
162
  try:
165
163
  self._apply_geom_flip_v_to_doc(doc)
@@ -173,7 +171,7 @@ class GeometryMixin:
173
171
  view = sw.widget() if sw else None
174
172
  doc = getattr(view, "document", None)
175
173
  if doc is None or getattr(doc, "image", None) is None:
176
- QMessageBox.information(self, "Rotate 90° CW", "Active view has no image.")
174
+ QMessageBox.information(self, self.tr("Rotate 90° CW"), self.tr("Active view has no image."))
177
175
  return
178
176
  try:
179
177
  self._apply_geom_rot_cw_to_doc(doc)
@@ -187,7 +185,7 @@ class GeometryMixin:
187
185
  view = sw.widget() if sw else None
188
186
  doc = getattr(view, "document", None)
189
187
  if doc is None or getattr(doc, "image", None) is None:
190
- QMessageBox.information(self, "Rotate 90° CCW", "Active view has no image.")
188
+ QMessageBox.information(self, self.tr("Rotate 90° CCW"), self.tr("Active view has no image."))
191
189
  return
192
190
  try:
193
191
  self._apply_geom_rot_ccw_to_doc(doc)
@@ -201,7 +199,7 @@ class GeometryMixin:
201
199
  view = sw.widget() if sw else None
202
200
  doc = getattr(view, "document", None)
203
201
  if doc is None or getattr(doc, "image", None) is None:
204
- QMessageBox.information(self, "Rotate 180°", "Active view has no image.")
202
+ QMessageBox.information(self, self.tr("Rotate 180°"), self.tr("Active view has no image."))
205
203
  return
206
204
  try:
207
205
  self._apply_geom_rot_180_to_doc(doc)
@@ -209,13 +207,51 @@ 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
215
251
  view = sw.widget() if sw else None
216
252
  doc = getattr(view, "document", None)
217
253
  if doc is None or getattr(doc, "image", None) is None:
218
- QMessageBox.information(self, "Rescale Image", "Active view has no image.")
254
+ QMessageBox.information(self, self.tr("Rescale Image"), self.tr("Active view has no image."))
219
255
  return
220
256
 
221
257
  # remember last value
@@ -223,8 +259,8 @@ class GeometryMixin:
223
259
  self._last_rescale_factor = 1.0
224
260
 
225
261
  dlg = QInputDialog(self)
226
- dlg.setWindowTitle("Rescale Image")
227
- dlg.setLabelText("Enter scaling factor (e.g., 0.5 for 50%, 2 for 200%):")
262
+ dlg.setWindowTitle(self.tr("Rescale Image"))
263
+ dlg.setLabelText(self.tr("Enter scaling factor (e.g., 0.5 for 50%, 2 for 200%):"))
228
264
  dlg.setInputMode(QInputDialog.InputMode.DoubleInput)
229
265
  dlg.setDoubleRange(0.1, 10.0)
230
266
  dlg.setDoubleDecimals(2)
@@ -249,7 +285,7 @@ class GeometryMixin:
249
285
  self._last_rescale_factor = factor
250
286
  self._log(f"Rescale ({factor:g}×) applied to active view")
251
287
  except Exception as e:
252
- QMessageBox.critical(self, "Rescale Image", str(e))
288
+ QMessageBox.critical(self, self.tr("Rescale Image"), str(e))
253
289
 
254
290
  # --- Geometry: headless apply-to-doc helpers ---
255
291
 
@@ -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)))
@@ -199,7 +199,7 @@ class HeaderMixin:
199
199
  # Fallback path: extract -> populate, all guarded.
200
200
  rows = self._extract_header_pairs(doc)
201
201
  if not rows:
202
- self._clear_header_viewer("No header" if doc else "No image")
202
+ self._clear_header_viewer(self.tr("No header") if doc else self.tr("No image"))
203
203
  else:
204
204
  self._populate_header_viewer(rows)
205
205
  except Exception as e:
@@ -245,7 +245,7 @@ class HeaderMixin:
245
245
  out.append((str(k), str(v), ""))
246
246
  return out
247
247
  if fmt == "repr":
248
- return [("Header", str(snap.get("text", "")), "")]
248
+ return [(self.tr("Header"), str(snap.get("text", "")), "")]
249
249
 
250
250
  # 2) Live header object(s) (can be astropy, dict, or random).
251
251
  hdr = (meta.get("original_header")
@@ -297,7 +297,7 @@ class HeaderMixin:
297
297
  return out
298
298
 
299
299
  # Fallback: string repr
300
- return [("Header", str(hdr), "")]
300
+ return [(self.tr("Header"), str(hdr), "")]
301
301
  except Exception as e:
302
302
  print("[header] extract suppressed:", e)
303
303
  return []
@@ -319,7 +319,7 @@ class HeaderMixin:
319
319
  try:
320
320
  w.setRowCount(0)
321
321
  w.setColumnCount(3)
322
- w.setHorizontalHeaderLabels(["Key", "Value", "Comment"])
322
+ w.setHorizontalHeaderLabels([self.tr("Key"), self.tr("Value"), self.tr("Comment")])
323
323
  for r, (k, v, c) in enumerate(rows):
324
324
  w.insertRow(r)
325
325
  w.setItem(r, 0, QTableWidgetItem(k))
@@ -370,7 +370,7 @@ class HeaderMixin:
370
370
  if isinstance(w, QTableWidget):
371
371
  w.setRowCount(0)
372
372
  w.setColumnCount(3)
373
- w.setHorizontalHeaderLabels(["Key", "Value", "Comment"])
373
+ w.setHorizontalHeaderLabels([self.tr("Key"), self.tr("Value"), self.tr("Comment")])
374
374
  return
375
375
  except Exception:
376
376
  pass
@@ -421,7 +421,7 @@ class HeaderMixin:
421
421
  def _on_doc_removed_for_header_sync(self, doc):
422
422
  """If the removed doc was the active one, clear header."""
423
423
  if doc is self._active_doc():
424
- self._clear_header_viewer("No image")
424
+ self._clear_header_viewer(self.tr("No image"))
425
425
  hv = getattr(self, "header_viewer", None)
426
426
  if hv and hasattr(hv, "set_document"):
427
427
  try:
@@ -32,7 +32,7 @@ class MaskMixin:
32
32
  doc = getattr(vw, "document", None)
33
33
  has_mask = bool(doc and getattr(doc, "active_mask_id", None))
34
34
  if not has_mask:
35
- QMessageBox.information(self, "Mask Overlay", "No active mask on this image.")
35
+ QMessageBox.information(self, self.tr("Mask Overlay"), self.tr("No active mask on this image."))
36
36
  return
37
37
  vw.show_mask_overlay = True
38
38
  # ensure visuals are up-to-date immediately
@@ -94,7 +94,7 @@ class MaskMixin:
94
94
 
95
95
  doc = self._current_document()
96
96
  if doc is None or getattr(doc, "image", None) is None:
97
- QMessageBox.information(self, "No image", "Open an image first.")
97
+ QMessageBox.information(self, self.tr("No image"), self.tr("Open an image first."))
98
98
  return
99
99
  created = create_mask_and_attach(self, doc)
100
100
  # Optional toast/log
@@ -200,12 +200,12 @@ class MaskMixin:
200
200
  """Show dialog to apply a mask from another document."""
201
201
  target_doc = self._active_doc()
202
202
  if not target_doc:
203
- QMessageBox.information(self, "Mask", "No active document.")
203
+ QMessageBox.information(self, self.tr("Mask"), self.tr("No active document."))
204
204
  return
205
205
 
206
206
  candidates = self._list_candidate_mask_sources(exclude_doc=target_doc)
207
207
  if not candidates:
208
- QMessageBox.information(self, "Mask", "Open another image to use as a mask.")
208
+ QMessageBox.information(self, self.tr("Mask"), self.tr("Open another image to use as a mask."))
209
209
  return
210
210
 
211
211
  # If there are multiple, ask which one to use
@@ -215,8 +215,8 @@ class MaskMixin:
215
215
  else:
216
216
  from PyQt6.QtWidgets import QInputDialog
217
217
  names = [f"{i + 1}. {d.display_name()}" for i, d in enumerate(candidates)]
218
- choice, ok = QInputDialog.getItem(self, "Choose Mask Image",
219
- "Use this image as mask:", names, 0, False)
218
+ choice, ok = QInputDialog.getItem(self, self.tr("Choose Mask Image"),
219
+ self.tr("Use this image as mask:"), names, 0, False)
220
220
  if not ok:
221
221
  return
222
222
  idx = names.index(choice)
@@ -286,7 +286,7 @@ class MaskMixin:
286
286
  return
287
287
  mid = getattr(doc, "active_mask_id", None)
288
288
  if not mid:
289
- QMessageBox.information(self, "Mask", "No active mask to remove.")
289
+ QMessageBox.information(self, self.tr("Mask"), self.tr("No active mask to remove."))
290
290
  return
291
291
  try:
292
292
  doc.remove_mask(mid)
@@ -347,7 +347,7 @@ class MaskMixin:
347
347
 
348
348
  if src_doc is None:
349
349
  print(f"[MainWindow] _handle_mask_drop: no src_doc for ptr={src_ptr}")
350
- QMessageBox.warning(self, "Mask", "Could not resolve mask document.")
350
+ QMessageBox.warning(self, self.tr("Mask"), self.tr("Could not resolve mask document."))
351
351
  return
352
352
 
353
353
  # --- 2) Resolve target view / doc ----------------------------------