setiastrosuitepro 1.6.7__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 (394) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,3125 @@
1
+ # pro/shortcuts.py
2
+ from __future__ import annotations
3
+ import json
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Optional
7
+ import uuid
8
+
9
+ from PyQt6.QtCore import (Qt, QPoint, QRect, QMimeData, QSettings, QByteArray,
10
+ QDataStream, QIODevice, QEvent, QSize)
11
+ from PyQt6.QtGui import (QAction, QDrag, QIcon, QMouseEvent, QPixmap, QKeyEvent, QKeyEvent, QCursor, QKeySequence)
12
+ from PyQt6.QtWidgets import (QToolBar, QWidget, QToolButton, QMenu, QApplication, QVBoxLayout, QHBoxLayout, QComboBox, QGroupBox, QGridLayout, QDoubleSpinBox, QSpinBox,
13
+ QInputDialog, QMessageBox, QDialog, QSlider,
14
+ QFormLayout, QDialogButtonBox, QDoubleSpinBox, QCheckBox, QLabel, QRubberBand, QRadioButton, QPlainTextEdit, QTabWidget, QLineEdit, QPushButton, QFileDialog)
15
+
16
+ from PyQt6.QtWidgets import QMdiArea, QMdiSubWindow
17
+ # _LinearFitPresetDialog loaded on demand (see line ~334)
18
+
19
+
20
+
21
+ try:
22
+ from PyQt6 import sip
23
+ except Exception:
24
+ sip = None
25
+
26
+ from setiastro.saspro.dnd_mime import MIME_VIEWSTATE, MIME_CMD, MIME_MASK, MIME_ACTION
27
+
28
+ from pathlib import Path
29
+ import os # ← NEW
30
+
31
+ SASS_KIND = "sas.shortcuts"
32
+ SASS_VER = 1
33
+
34
+ TOOLBAR_REORDER_MIME = "application/x-saspro-toolbar-reorder"
35
+ # Accept these endings (case-insensitive)
36
+ OPENABLE_ENDINGS = (
37
+ ".png", ".jpg", ".jpeg",
38
+ ".tif", ".tiff",
39
+ ".fits", ".fit",
40
+ ".fits.gz", ".fit.gz", ".fz",
41
+ ".xisf",
42
+ ".cr2", ".cr3", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2", ".pef",
43
+ )
44
+
45
+ _ICONS = None
46
+
47
+ def _get_icons():
48
+ """Lazy-load icons so shortcuts.py can be imported early without circular deps."""
49
+ global _ICONS
50
+ if _ICONS is not None:
51
+ return _ICONS
52
+
53
+ # Find where get_icons() lives in your project and import it here.
54
+ # Try a couple common locations; keep the first that exists in your tree.
55
+ try:
56
+ from setiastro.saspro.resources import get_icons as _gi
57
+ except Exception:
58
+ _gi = None
59
+
60
+ _ICONS = _gi() if _gi else None
61
+ return _ICONS
62
+
63
+
64
+ def _is_dead(w) -> bool:
65
+ """True if widget is None or its C++ has been destroyed."""
66
+ if w is None:
67
+ return True
68
+ if sip is not None:
69
+ try:
70
+ return sip.isdeleted(w)
71
+ except Exception:
72
+ return False
73
+ # sip not available: best-effort heuristic
74
+ try:
75
+ _ = w.parent() # will raise on dead wrappers
76
+ return False
77
+ except RuntimeError:
78
+ return True
79
+
80
+ # ---------- constants / helpers ----------
81
+
82
+ SET_KEY_V1 = "Shortcuts/v1" # legacy (id-less)
83
+ SET_KEY_V2 = "Shortcuts/v2" # new: stores id, label, etc.
84
+ SET_KEY = SET_KEY_V2
85
+ KEYBINDS_KEY = "Keybinds/v1" # JSON dict: {command_id: "Ctrl+Alt+S"}
86
+
87
+ # Used when dragging a DESKTOP shortcut onto a view for headless run
88
+
89
+
90
+ def _pack_cmd_payload(command_id: str, preset: dict | None = None) -> bytes:
91
+ return json.dumps({"command_id": command_id, "preset": preset or {}}).encode("utf-8")
92
+
93
+ def _unpack_cmd_payload(b: bytes) -> dict:
94
+ return json.loads(b.decode("utf-8"))
95
+
96
+
97
+ @dataclass
98
+ class ShortcutEntry:
99
+ shortcut_id: str
100
+ command_id: str
101
+ x: int
102
+ y: int
103
+ label: str
104
+
105
+ # ---------- a QToolBar that supports Alt+drag to create shortcuts ----------
106
+ class DraggableToolBar(QToolBar):
107
+ """
108
+ Alt/Ctrl/Shift + Left-drag a toolbar button to create a desktop shortcut.
109
+ We hook QToolButton children (not the toolbar itself), because
110
+ mouse events go to the buttons.
111
+ """
112
+ def __init__(self, *a, **k):
113
+ super().__init__(*a, **k)
114
+ self._press_pos: dict[QToolButton, QPoint] = {}
115
+ self._dragging_from: QToolButton | None = None
116
+ self._press_had_mod: dict[QToolButton, bool] = {}
117
+ self._suppress_release: set[QToolButton] = set()
118
+ self._settings_key: str | None = None
119
+ self.setAcceptDrops(True)
120
+
121
+ def _mods_ok(self, mods: Qt.KeyboardModifiers) -> bool:
122
+ return bool(mods & (
123
+ Qt.KeyboardModifier.AltModifier |
124
+ Qt.KeyboardModifier.ControlModifier |
125
+ Qt.KeyboardModifier.ShiftModifier
126
+ ))
127
+
128
+ # NEW: called by main window / mixin
129
+ def setSettingsKey(self, key: str):
130
+ self._settings_key = str(key)
131
+
132
+ def _settings(self):
133
+ mw = self.window()
134
+ s = getattr(mw, "settings", None)
135
+ if s is None:
136
+ from PyQt6.QtCore import QSettings
137
+ s = QSettings()
138
+ return s
139
+
140
+ def _action_id(self, act: QAction) -> str | None:
141
+ cid = act.property("command_id") or act.objectName()
142
+ return str(cid) if cid else None
143
+
144
+ def _hidden_key(self) -> str | None:
145
+ if not self._settings_key:
146
+ return None
147
+ return f"{self._settings_key}/Hidden"
148
+
149
+ def _load_hidden_set(self) -> set[str]:
150
+ k = self._hidden_key()
151
+ if not k:
152
+ return set()
153
+ s = self._settings()
154
+ raw = s.value(k, [], type=list)
155
+ # QSettings sometimes returns strings instead of list depending on backend
156
+ if isinstance(raw, str):
157
+ return {raw}
158
+ return {str(x) for x in (raw or [])}
159
+
160
+ def _save_hidden_set(self, hidden: set[str]):
161
+ k = self._hidden_key()
162
+ if not k:
163
+ return
164
+ s = self._settings()
165
+ s.setValue(k, sorted(hidden))
166
+
167
+ def _set_action_hidden(self, act: QAction, hide: bool):
168
+ cid = self._action_id(act)
169
+ if not cid:
170
+ return
171
+ hidden = self._load_hidden_set()
172
+ if hide:
173
+ hidden.add(cid)
174
+ act.setVisible(False)
175
+ else:
176
+ hidden.discard(cid)
177
+ act.setVisible(True)
178
+ self._save_hidden_set(hidden)
179
+
180
+ def apply_hidden_state(self):
181
+ """Call after toolbar is populated / order restored."""
182
+ hidden = self._load_hidden_set()
183
+ if not hidden:
184
+ return
185
+ for act in self.actions():
186
+ cid = self._action_id(act)
187
+ if cid and cid in hidden:
188
+ act.setVisible(False)
189
+
190
+
191
+ def _persist_order(self):
192
+ """Persist current action order (by command_id/objectName) to QSettings."""
193
+ if not self._settings_key:
194
+ return
195
+
196
+ # Prefer main-window settings if available
197
+ mw = self.window()
198
+ s = getattr(mw, "settings", None)
199
+ if s is None:
200
+ from PyQt6.QtCore import QSettings
201
+ s = QSettings()
202
+
203
+ ids: list[str] = []
204
+ for act in self.actions():
205
+ cid = act.property("command_id") or act.objectName()
206
+ if cid:
207
+ ids.append(str(cid))
208
+
209
+ s.setValue(self._settings_key, ids)
210
+
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
+
223
+ # install/remove our event filter when actions are added/removed
224
+ def actionEvent(self, e):
225
+ super().actionEvent(e)
226
+ t = e.type()
227
+ if t == QEvent.Type.ActionAdded:
228
+ act = e.action()
229
+ btn = self.widgetForAction(act)
230
+ if isinstance(btn, QToolButton):
231
+ btn.installEventFilter(self)
232
+ elif t == QEvent.Type.ActionRemoved:
233
+ act = e.action()
234
+ btn = self.widgetForAction(act)
235
+ if isinstance(btn, QToolButton):
236
+ try:
237
+ btn.removeEventFilter(self)
238
+ except Exception:
239
+ pass
240
+
241
+ def eventFilter(self, obj, ev):
242
+ if isinstance(obj, QToolButton):
243
+ # RIGHT CLICK → show "Create Desktop Shortcut"
244
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.RightButton:
245
+ act = self._find_action_for_button(obj)
246
+ if act:
247
+ self._show_toolbutton_context_menu(obj, act, ev.globalPosition().toPoint())
248
+ return True # consume
249
+ return False
250
+
251
+ # Keyboard/trackpad context menu event
252
+ if ev.type() == QEvent.Type.ContextMenu:
253
+ act = self._find_action_for_button(obj)
254
+ if act:
255
+ self._show_toolbutton_context_menu(obj, act, ev.globalPos())
256
+ return True
257
+ return False
258
+
259
+ # L-press: remember start + whether a drag-modifier was held
260
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
261
+ self._press_pos[obj] = ev.globalPosition().toPoint()
262
+ self._press_had_mod[obj] = self._mods_ok(QApplication.keyboardModifiers())
263
+ return False # allow normal press visuals
264
+
265
+ # Move with L held:
266
+ if ev.type() == QEvent.Type.MouseMove and (ev.buttons() & Qt.MouseButton.LeftButton):
267
+ start = self._press_pos.get(obj)
268
+ if start is not None:
269
+ delta = ev.globalPosition().toPoint() - start
270
+ if delta.manhattanLength() > QApplication.startDragDistance():
271
+ mods_now = QApplication.keyboardModifiers()
272
+ had_mod = self._press_had_mod.get(obj, False)
273
+
274
+ # CASE 1: had/has modifiers → create desktop shortcut / function-bundle drag (existing behavior)
275
+ if had_mod or self._mods_ok(mods_now):
276
+ act = self._find_action_for_button(obj)
277
+ if act:
278
+ self._start_drag_for_action(act)
279
+ self._suppress_release.add(obj)
280
+ self._press_pos.pop(obj, None)
281
+ self._press_had_mod.pop(obj, None)
282
+ return True # consume
283
+ else:
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
+
299
+ self._start_reorder_drag_for_button(obj)
300
+ self._suppress_release.add(obj)
301
+ self._press_pos.pop(obj, None)
302
+ self._press_had_mod.pop(obj, None)
303
+ return True # consume
304
+
305
+ return False
306
+
307
+ # Release: if we started any drag, swallow the release so click won't fire
308
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
309
+ self._press_pos.pop(obj, None)
310
+ self._press_had_mod.pop(obj, None)
311
+ if obj in self._suppress_release:
312
+ self._suppress_release.discard(obj)
313
+ return True # eat release → no click
314
+ return False
315
+
316
+ return super().eventFilter(obj, ev)
317
+
318
+
319
+ def _start_drag_for_action(self, act: QAction):
320
+ act_id = act.property("command_id") or act.objectName()
321
+ if not act_id:
322
+ return
323
+
324
+ mods = QApplication.keyboardModifiers()
325
+ alt = bool(mods & Qt.KeyboardModifier.AltModifier)
326
+
327
+ md = QMimeData()
328
+ if alt:
329
+ # Put BOTH payloads on the drag:
330
+ # 1) MIME_CMD → lets you drop directly on a View or Function Bundle chip
331
+ # (use per-command preset if available, else empty dict)
332
+ s = QSettings()
333
+ raw = s.value(f"presets/{act_id}", "", type=str) or ""
334
+ try:
335
+ preset = json.loads(raw) if raw else {}
336
+ except Exception:
337
+ preset = {}
338
+ md.setData(MIME_CMD, _pack_cmd_payload(act_id, preset))
339
+
340
+ # 2) MIME_ACTION → canvas still interprets this to create a desktop shortcut
341
+ md.setData(MIME_ACTION, act_id.encode("utf-8"))
342
+ else:
343
+ # Ctrl/Shift (legacy): only create a desktop shortcut
344
+ md.setData(MIME_ACTION, act_id.encode("utf-8"))
345
+
346
+ drag = QDrag(self)
347
+ drag.setMimeData(md)
348
+ pm = act.icon().pixmap(32, 32) if not act.icon().isNull() else QPixmap(32, 32)
349
+ if pm.isNull():
350
+ pm = QPixmap(32, 32); pm.fill(Qt.GlobalColor.darkGray)
351
+ drag.setPixmap(pm)
352
+ drag.setHotSpot(pm.rect().center())
353
+ drag.exec(Qt.DropAction.CopyAction)
354
+
355
+ def _start_reorder_drag_for_button(self, btn: QToolButton):
356
+ """
357
+ Start a drag whose only purpose is to reorder actions within THIS toolbar.
358
+ No presets, no desktop shortcuts.
359
+ """
360
+ act = self._find_action_for_button(btn)
361
+ if act is None:
362
+ return
363
+
364
+ md = QMimeData()
365
+ # Tag this as an internal toolbar-reorder drag
366
+ md.setData(TOOLBAR_REORDER_MIME, b"1")
367
+
368
+ drag = QDrag(btn)
369
+ drag.setMimeData(md)
370
+
371
+ pm = act.icon().pixmap(32, 32) if not act.icon().isNull() else QPixmap(32, 32)
372
+ if pm.isNull():
373
+ pm = QPixmap(32, 32)
374
+ pm.fill(Qt.GlobalColor.darkGray)
375
+ drag.setPixmap(pm)
376
+ drag.setHotSpot(pm.rect().center())
377
+ drag.exec(Qt.DropAction.MoveAction)
378
+
379
+
380
+ def _find_action_for_button(self, btn: QToolButton) -> QAction | None:
381
+ # Find the QAction that owns this toolbutton
382
+ for a in self.actions():
383
+ if self.widgetForAction(a) is btn:
384
+ return a
385
+ return None
386
+
387
+ def dragEnterEvent(self, e):
388
+ # Accept toolbar-reorder drags from any DraggableToolBar
389
+ if e.mimeData().hasFormat(TOOLBAR_REORDER_MIME):
390
+ src = e.source()
391
+ if isinstance(src, QToolButton) and isinstance(src.parent(), DraggableToolBar):
392
+ e.acceptProposedAction()
393
+ return
394
+ super().dragEnterEvent(e)
395
+
396
+ def dragMoveEvent(self, e):
397
+ if e.mimeData().hasFormat(TOOLBAR_REORDER_MIME):
398
+ src = e.source()
399
+ if isinstance(src, QToolButton) and isinstance(src.parent(), DraggableToolBar):
400
+ e.acceptProposedAction()
401
+ return
402
+ super().dragMoveEvent(e)
403
+
404
+ def dropEvent(self, e):
405
+ if e.mimeData().hasFormat(TOOLBAR_REORDER_MIME):
406
+ src_btn = e.source()
407
+ if isinstance(src_btn, QToolButton):
408
+ src_tb = src_btn.parent()
409
+ if isinstance(src_tb, DraggableToolBar):
410
+ # Find the QAction that belongs to the dragged button
411
+ src_act = src_tb._find_action_for_button(src_btn)
412
+ if src_act is None:
413
+ e.ignore()
414
+ return
415
+
416
+ pos = e.position().toPoint()
417
+ target_act = self.actionAt(pos)
418
+
419
+ # Remove from source toolbar and insert into this one
420
+ src_tb.removeAction(src_act)
421
+ if target_act is None:
422
+ self.addAction(src_act)
423
+ else:
424
+ self.insertAction(target_act, src_act)
425
+
426
+ # Persist order for both toolbars
427
+ self._persist_order()
428
+ if src_tb is not self:
429
+ src_tb._persist_order()
430
+ # Also persist the cross-toolbar assignment
431
+ self._update_assignment_for_action(src_act)
432
+
433
+ e.acceptProposedAction()
434
+ return
435
+
436
+ super().dropEvent(e)
437
+
438
+
439
+ def _update_assignment_for_action(self, act: QAction):
440
+ """
441
+ Persist that this action now belongs to this toolbar (cross-toolbar move).
442
+ Stored as: Toolbar/Assignments → JSON {command_id: settings_key}.
443
+ """
444
+ if not self._settings_key:
445
+ return
446
+
447
+ cid = act.property("command_id") or act.objectName()
448
+ if not cid:
449
+ return
450
+
451
+ mw = self.window()
452
+ s = getattr(mw, "settings", None)
453
+ if s is None:
454
+ s = QSettings()
455
+
456
+ raw = s.value("Toolbar/Assignments", "", type=str) or ""
457
+ try:
458
+ mapping = json.loads(raw) if raw else {}
459
+ except Exception:
460
+ mapping = {}
461
+
462
+ mapping[str(cid)] = self._settings_key
463
+ s.setValue("Toolbar/Assignments", json.dumps(mapping))
464
+
465
+
466
+ def _add_shortcut_for_action(self, act: QAction):
467
+ # Resolve command id
468
+ act_id = act.property("command_id") or act.objectName()
469
+ if not act_id:
470
+ return
471
+ # Find ShortcutManager on the main window
472
+ mw = self.window()
473
+ mgr = getattr(mw, "shortcuts", None)
474
+ mdi = getattr(mw, "mdi", None)
475
+ if mgr is None or mdi is None:
476
+ return
477
+ # Map current cursor pos (global) into the viewport
478
+ gpos = QCursor.pos()
479
+ vp = mdi.viewport()
480
+ pos = vp.mapFromGlobal(gpos)
481
+ # Clamp into viewport rect (center if way out of bounds)
482
+ rect = vp.rect()
483
+ if not rect.contains(pos):
484
+ pos = rect.center()
485
+ mgr.add_shortcut(str(act_id), pos)
486
+
487
+ def _show_toolbutton_context_menu(self, btn: QToolButton, act: QAction, gpos: QPoint):
488
+ m = QMenu(btn)
489
+
490
+ m.addAction(self.tr("Create Desktop Shortcut"), lambda: self._add_shortcut_for_action(act))
491
+
492
+ # Hide this icon
493
+ cid = self._action_id(act)
494
+ if cid:
495
+ m.addSeparator()
496
+ m.addAction(self.tr("Hide this icon"), lambda: self.window()._hide_action_to_hidden_toolbar(act))
497
+
498
+
499
+ # (Optional) teach users about Alt+Drag:
500
+ m.addSeparator()
501
+ tip = m.addAction(self.tr("Tip: Alt+Drag to create"))
502
+ tip.setEnabled(False)
503
+
504
+ m.exec(gpos)
505
+
506
+
507
+ def contextMenuEvent(self, ev):
508
+ # Right-click on empty toolbar area
509
+ m = QMenu(self)
510
+
511
+ # 1. Lock/Unlock Action
512
+ is_locked = self._is_locked()
513
+ act_lock = m.addAction(self.tr("Lock Toolbar Icons"))
514
+ act_lock.setCheckable(True)
515
+ act_lock.setChecked(is_locked)
516
+
517
+ def _toggle_lock(checked):
518
+ self._set_locked(checked)
519
+
520
+ act_lock.triggered.connect(_toggle_lock)
521
+
522
+ m.addSeparator()
523
+
524
+ # Submenu listing hidden actions for this toolbar
525
+ sub = m.addMenu(self.tr("Show hidden…"))
526
+
527
+ mw = self.window()
528
+ tb_hidden = getattr(mw, "_hidden_toolbar", lambda: None)()
529
+ any_hidden = False
530
+ if tb_hidden:
531
+ for act in tb_hidden.actions():
532
+ # Skip separators
533
+ if act.isSeparator():
534
+ continue
535
+ any_hidden = True
536
+ sub.addAction(act.text() or (act.property("command_id") or act.objectName() or "item"),
537
+ lambda a=act: mw._unhide_action_from_hidden_toolbar(a))
538
+
539
+ if not any_hidden:
540
+ sub.setEnabled(False)
541
+
542
+ m.addSeparator()
543
+ m.addAction(self.tr("Reset hidden icons"), self._reset_hidden_icons)
544
+
545
+ m.exec(ev.globalPos())
546
+
547
+ def _reset_hidden_icons(self):
548
+ mw = self.window()
549
+ tb_hidden = getattr(mw, "_hidden_toolbar", lambda: None)()
550
+ if not tb_hidden:
551
+ return
552
+ # copy list because we'll mutate
553
+ acts = [a for a in tb_hidden.actions() if not a.isSeparator()]
554
+ for a in acts:
555
+ mw._unhide_action_from_hidden_toolbar(a)
556
+
557
+
558
+
559
+ _PRESET_UI_IDS = {
560
+ "stat_stretch","star_stretch","crop","curves","ghs","abe","graxpert",
561
+ "remove_stars","cosmic_clarity","cosmic","cosmicclarity",
562
+ "convo","convolution","deconvolution","convo_deconvo",
563
+ "linear_fit","wavescale_hdr","wavescale_dark_enhance","wavescale_dark_enhancer",
564
+ "remove_green","star_align","background_neutral","white_balance","clahe",
565
+ "morphology","pixel_math","rgb_align","signature_insert","signature_adder",
566
+ "signature","halo_b_gon","geom_rescale","rescale","debayer","image_combine",
567
+ "star_spikes","diffraction_spikes", "multiscale_decomp","geom_rotate_any",
568
+ }
569
+
570
+ def _has_preset_editor_for_command(command_id: str) -> bool:
571
+ """Return True if we have a bespoke UI for this command_id."""
572
+ return command_id in _PRESET_UI_IDS
573
+
574
+ # ---- Shared preset editor helper for other modules (e.g. Function Bundles) ----
575
+ def _open_preset_editor_for_command(parent, command_id: str, initial: dict | None):
576
+ """
577
+ Open the same command-specific preset editor UIs used by ShortcutButton.
578
+ Returns a dict on success (OK), or None if cancelled / no editor available.
579
+ """
580
+ cur = initial or {}
581
+
582
+ # Keep each branch self-contained with local imports to avoid heavy module churn.
583
+ if command_id == "stat_stretch":
584
+ dlg = _StatStretchPresetDialog(parent, initial=cur)
585
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
586
+
587
+ if command_id == "star_stretch":
588
+ dlg = _StarStretchPresetDialog(parent, initial=cur)
589
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
590
+
591
+ if command_id == "crop":
592
+ from setiastro.saspro.shortcuts import _CropPresetDialog
593
+ dlg = _CropPresetDialog(parent, initial=cur or {
594
+ "mode": "margins",
595
+ "margins": {"top": 0, "right": 0, "bottom": 0, "left": 0},
596
+ "create_new_view": False
597
+ })
598
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
599
+
600
+ if command_id == "geom_rotate_any":
601
+ dlg = _GeomRotateAnyPresetDialog(parent, initial=cur or {
602
+ "angle_deg": 0.0,
603
+ })
604
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
605
+
606
+ if command_id == "curves":
607
+ dlg = _CurvesPresetDialog(parent, initial=cur or {"shape":"linear","amount":0.5,"mode":"K (Brightness)"})
608
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
609
+
610
+ if command_id == "ghs":
611
+ dlg = _GHSPresetDialog(parent, initial=cur or {
612
+ "alpha":1.0,"beta":1.0,"gamma":1.0,"pivot":0.5,"lp":0.0,"hp":0.0,"channel":"K (Brightness)"
613
+ })
614
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
615
+
616
+ if command_id == "abe":
617
+ dlg = _ABEPresetDialog(parent, initial=cur or {
618
+ "degree":2, "samples":120, "downsample":6, "patch":15, "rbf":True, "rbf_smooth":1.0, "make_background_doc":False
619
+ })
620
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
621
+
622
+ if command_id == "graxpert":
623
+ from setiastro.saspro.graxpert_preset import GraXpertPresetDialog
624
+ dlg = GraXpertPresetDialog(parent, initial=cur or {"smoothing":0.10,"gpu":True})
625
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
626
+
627
+ if command_id == "remove_stars":
628
+ from setiastro.saspro.remove_stars_preset import RemoveStarsPresetDialog
629
+ dlg = RemoveStarsPresetDialog(parent, initial=cur or {"tool":"starnet","linear":True})
630
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
631
+
632
+ if command_id in ("cosmic_clarity","cosmic","cosmicclarity"):
633
+ from setiastro.saspro.cosmicclarity_preset import _CosmicClarityPresetDialog
634
+ dlg = _CosmicClarityPresetDialog(parent, initial=cur or {
635
+ "mode":"sharpen","gpu":True,"create_new_view":False,"sharpening_mode":"Both",
636
+ "auto_psf":True,"nonstellar_psf":3.0,"stellar_amount":0.50,"nonstellar_amount":0.50,
637
+ "denoise_luma":0.50,"denoise_color":0.50,"denoise_mode":"full","separate_channels":False,"scale":2
638
+ })
639
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
640
+
641
+ if command_id in ("convo","convolution","deconvolution","convo_deconvo"):
642
+ from setiastro.saspro.convo_preset import ConvoPresetDialog
643
+ dlg = ConvoPresetDialog(parent, initial=cur or {
644
+ "op":"convolution","radius":5.0,"kurtosis":2.0,"aspect":1.0,"rotation":0.0,"strength":1.0
645
+ })
646
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
647
+
648
+ if command_id == "linear_fit":
649
+ from setiastro.saspro.linear_fit import _LinearFitPresetDialog
650
+ dlg = _LinearFitPresetDialog(parent, initial=cur)
651
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
652
+
653
+ if command_id == "wavescale_hdr":
654
+ from setiastro.saspro.wavescale_hdr_preset import WaveScaleHDRPresetDialog
655
+ dlg = WaveScaleHDRPresetDialog(parent, initial=cur or {"n_scales":5,"compression_factor":1.5,"mask_gamma":5.0})
656
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
657
+
658
+ if command_id in ("wavescale_dark_enhance","wavescale_dark_enhancer"):
659
+ from setiastro.saspro.wavescalede_preset import WaveScaleDSEPresetDialog
660
+ dlg = WaveScaleDSEPresetDialog(parent, initial=cur or {
661
+ "n_scales":6,"boost_factor":5.0,"mask_gamma":1.0,"iterations":2
662
+ })
663
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
664
+
665
+ if command_id == "multiscale_decomp":
666
+ from setiastro.saspro.multiscale_decomp import _MultiScaleDecompPresetDialog
667
+ dlg = _MultiScaleDecompPresetDialog(parent, initial=cur or {
668
+ "layers": 4,
669
+ "base_sigma": 1.0,
670
+ "linked_rgb": True,
671
+ "layers_cfg": [],
672
+ })
673
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
674
+
675
+ if command_id == "remove_green":
676
+ dlg = _RemoveGreenPresetDialog(parent, initial=cur)
677
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
678
+
679
+ if command_id == "star_align":
680
+ from setiastro.saspro.star_alignment_preset import StarAlignmentPresetDialog
681
+ dlg = StarAlignmentPresetDialog(parent, initial=cur or {"ref_mode":"active","overwrite":False,"downsample":2})
682
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
683
+
684
+ if command_id == "background_neutral":
685
+ dlg = _BackgroundNeutralPresetDialog(parent, initial=cur)
686
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
687
+
688
+ if command_id == "white_balance":
689
+ dlg = _WhiteBalancePresetDialog(parent, initial=cur)
690
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
691
+
692
+ if command_id == "clahe":
693
+ dlg = _CLAHEPresetDialog(parent, initial=cur)
694
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
695
+
696
+ if command_id == "morphology":
697
+ dlg = _MorphologyPresetDialog(parent, initial=cur)
698
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
699
+
700
+ if command_id == "pixel_math":
701
+ dlg = _PixelMathPresetDialog(parent, initial=cur)
702
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
703
+
704
+ if command_id == "rgb_align":
705
+ dlg = _RGBAlignPresetDialog(parent, initial=cur or {"model":"homography","new_doc":True})
706
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
707
+
708
+ if command_id in ("signature_insert","signature_adder","signature"):
709
+ dlg = _SignatureInsertPresetDialog(parent, initial=cur)
710
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
711
+
712
+ if command_id == "halo_b_gon":
713
+ dlg = _HaloBGonPresetDialog(parent, initial=cur)
714
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
715
+
716
+ if command_id in ("geom_rescale","rescale"):
717
+ dlg = _RescalePresetDialog(parent, initial=cur or {"factor":1.0})
718
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
719
+
720
+ if command_id == "debayer":
721
+ dlg = _DebayerPresetDialog(parent, initial=cur or {"pattern":"auto"})
722
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
723
+
724
+ if command_id == "image_combine":
725
+ dlg = _ImageCombinePresetDialog(parent, initial=cur or {
726
+ "mode":"Blend","opacity":1.0,"luma_only":False,"output":"replace","docB_title":""
727
+ })
728
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
729
+
730
+ if command_id in ("star_spikes","diffraction_spikes"):
731
+ dlg = _StarSpikesPresetDialog(parent, initial=cur)
732
+ return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
733
+
734
+ # Unknown / no bespoke UI
735
+ return None
736
+
737
+
738
+ # ---------- the button that sits on the MDI desktop ----------
739
+ class ShortcutButton(QToolButton):
740
+ def __init__(self,
741
+ manager: "ShortcutManager",
742
+ sid: str, # NEW
743
+ command_id: str,
744
+ icon: QIcon,
745
+ label: str, # NEW (display text)
746
+ parent: QWidget):
747
+ super().__init__(parent)
748
+ self._mgr = manager
749
+ self.sid = sid # NEW
750
+ self.command_id = command_id
751
+ self.setIcon(icon)
752
+ self.setText(label) # use label instead of action text
753
+ self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
754
+ self.setIconSize(QPixmap(32, 32).size())
755
+ self.setAutoRaise(True)
756
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
757
+ self.customContextMenuRequested.connect(self._context_menu)
758
+ self._dragging = False
759
+ self._press_pos = None
760
+ self._start_geom = None
761
+ self._did_command_drag = False
762
+ self.setToolTip(
763
+ f"{label}\n• Double-click: open\n• Drag: move\n• Alt/Ctrl+Drag onto a view: headless apply"
764
+ )
765
+
766
+ # --- Preset helpers (QSettings) -------------------------------------
767
+ def _preset_key(self) -> str:
768
+ # per-instance key
769
+ return f"presets/shortcuts/{self.sid}"
770
+
771
+ def _load_preset(self) -> Optional[dict]:
772
+ s = getattr(self._mgr, "settings", QSettings())
773
+ raw = s.value(self._preset_key(), "", type=str) or ""
774
+ if raw:
775
+ try:
776
+ return json.loads(raw)
777
+ except Exception:
778
+ pass
779
+ # fallback: legacy per-command preset if instance hasn’t been saved yet
780
+ legacy = s.value(f"presets/{self.command_id}", "", type=str) or ""
781
+ if legacy:
782
+ try:
783
+ return json.loads(legacy)
784
+ except Exception:
785
+ pass
786
+ return None
787
+
788
+ def _save_preset(self, preset: Optional[dict]):
789
+ s = getattr(self._mgr, "settings", QSettings())
790
+ if preset is None:
791
+ s.remove(self._preset_key())
792
+ else:
793
+ s.setValue(self._preset_key(), json.dumps(preset))
794
+ s.sync()
795
+
796
+ # --- Context menu (run / preset / delete) ----------------------------
797
+ def _context_menu(self, pos):
798
+ m = QMenu(self)
799
+ m.addAction(self.tr("Run"), lambda: self._mgr.trigger(self.command_id))
800
+ m.addSeparator()
801
+ m.addAction(self.tr("Edit Preset…"), self._edit_preset_ui)
802
+ m.addAction(self.tr("Clear Preset"), lambda: self._save_preset(None))
803
+ m.addAction(self.tr("Rename…"), self._rename) # ← NEW
804
+ m.addSeparator()
805
+ m.addAction(self.tr("Delete"), self._delete)
806
+ m.exec(self.mapToGlobal(pos))
807
+
808
+ def _rename(self):
809
+ current = self.text()
810
+ new_name, ok = QInputDialog.getText(self, self.tr("Rename Shortcut"), self.tr("Name:"), text=current)
811
+ if not ok or not new_name.strip():
812
+ return
813
+ self.setText(new_name.strip())
814
+ self._mgr.update_label(self.sid, new_name.strip()) # ← was self.shortcut_id
815
+
816
+ def _edit_preset_ui(self):
817
+ cid = self.command_id
818
+ cur = self._load_preset() or {}
819
+ result = _open_preset_editor_for_command(self, cid, cur)
820
+ if result is not None:
821
+ self._save_preset(result)
822
+ QMessageBox.information(self, self.tr("Preset saved"), self.tr("Preset stored on shortcut."))
823
+ return
824
+
825
+ # Fallback: JSON editor
826
+ raw = json.dumps(cur or {}, indent=2)
827
+ text, ok = QInputDialog.getMultiLineText(self, self.tr("Edit Preset (JSON)"), self.tr("Preset:"), raw)
828
+ if ok:
829
+ try:
830
+ preset = json.loads(text or "{}")
831
+ if not isinstance(preset, dict):
832
+ raise ValueError(self.tr("Preset must be a JSON object"))
833
+ self._save_preset(preset)
834
+ QMessageBox.information(self, self.tr("Preset saved"), self.tr("Preset stored on shortcut."))
835
+ except Exception as e:
836
+ QMessageBox.warning(self, self.tr("Invalid JSON"), str(e))
837
+
838
+
839
+ def _start_command_drag(self):
840
+ md = QMimeData()
841
+
842
+ md.setData(MIME_CMD, _pack_cmd_payload(self.command_id, self._load_preset() or {}))
843
+ drag = QDrag(self)
844
+ drag.setMimeData(md)
845
+ pm = self.icon().pixmap(32, 32)
846
+ if pm.isNull():
847
+ pm = QPixmap(32, 32); pm.fill(Qt.GlobalColor.darkGray)
848
+ drag.setPixmap(pm)
849
+ drag.setHotSpot(pm.rect().center())
850
+ drag.exec(Qt.DropAction.CopyAction)
851
+ self._did_command_drag = True
852
+
853
+ # --- Mouse handlers --------------------------------------------------
854
+ def _mods_mean_command_drag(self) -> bool:
855
+ # Use ALT only for headless drag so Ctrl/Shift can be used for multiselect
856
+ return bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier)
857
+
858
+ def mousePressEvent(self, e: QMouseEvent):
859
+ if e.button() == Qt.MouseButton.LeftButton:
860
+ mods = QApplication.keyboardModifiers()
861
+
862
+ if mods & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier):
863
+ self._mgr.toggle_select(self.sid) # ← was self.shortcut_id
864
+ return
865
+
866
+ if self.sid not in self._mgr.selected: # ← was self.shortcut_id
867
+ self._mgr.select_only(self.sid)
868
+
869
+ self._dragging = True
870
+ self._press_pos = e.globalPosition().toPoint()
871
+ self._last_drag_pos = self._press_pos
872
+ self._did_command_drag = False
873
+
874
+ super().mousePressEvent(e)
875
+
876
+ def mouseMoveEvent(self, e: QMouseEvent):
877
+ if self._dragging and self._press_pos is not None:
878
+ cur = e.globalPosition().toPoint()
879
+ step = cur - self._last_drag_pos
880
+ if step.manhattanLength() < QApplication.startDragDistance():
881
+ return super().mouseMoveEvent(e)
882
+
883
+ # If exactly 1 selected and ALT held → command drag (headless)
884
+ if len(self._mgr.selected) == 1 and self._mods_mean_command_drag():
885
+ self._start_command_drag()
886
+ return
887
+
888
+ # Otherwise: move the whole selection by step delta
889
+ self._mgr.move_selected_by(step.x(), step.y())
890
+ self._last_drag_pos = cur
891
+ return
892
+
893
+ super().mouseMoveEvent(e)
894
+
895
+ def mouseReleaseEvent(self, e: QMouseEvent):
896
+ if self._dragging and e.button() == Qt.MouseButton.LeftButton:
897
+ self._dragging = False
898
+ if not self._did_command_drag:
899
+ self._mgr.save_shortcuts() # persist positions after move
900
+ super().mouseReleaseEvent(e)
901
+
902
+ def mouseDoubleClickEvent(self, e: QMouseEvent):
903
+ # double-click still runs the action (open dialog)
904
+ self._mgr.trigger(self.command_id)
905
+
906
+ def _delete(self):
907
+ self._mgr.delete_by_id(self.sid, persist=True) # ← was command_id
908
+
909
+
910
+ def _open_view_bundles_from_canvas(w):
911
+ try:
912
+ from setiastro.saspro.view_bundle import show_view_bundles
913
+ mw = _find_main_window(w)
914
+ show_view_bundles(mw)
915
+ except Exception:
916
+ pass
917
+
918
+ def _open_function_bundles_from_canvas(w):
919
+ try:
920
+ from setiastro.saspro.function_bundle import show_function_bundles
921
+ mw = _find_main_window(w)
922
+ show_function_bundles(mw)
923
+ except Exception:
924
+ pass
925
+
926
+ def _find_main_window(w):
927
+ p = w.parent()
928
+ while p is not None and not (hasattr(p, "doc_manager") or hasattr(p, "docman")):
929
+ p = p.parent()
930
+ return p
931
+
932
+ # ---------- overlay canvas that sits on top of QMdiArea.viewport() ----------
933
+ class ShortcutCanvas(QWidget):
934
+ def __init__(self, mgr: "ShortcutManager", parent: QWidget):
935
+ super().__init__(parent)
936
+ self._mgr = mgr
937
+ self.setAcceptDrops(True)
938
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
939
+ self.setStyleSheet("background: transparent;")
940
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
941
+ self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
942
+ self.setGeometry(parent.rect())
943
+ parent.installEventFilter(self) # keep in sync with viewport size
944
+
945
+ # NEW: rubber-band selection
946
+ self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, self)
947
+ self._rubber_origin = None
948
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # to receive Delete/Ctrl+A
949
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
950
+
951
+ def eventFilter(self, obj, ev):
952
+ # keep sized with viewport
953
+ if obj is self.parent() and ev.type() == ev.Type.Resize:
954
+ self.setGeometry(self.parent().rect())
955
+ return super().eventFilter(obj, ev)
956
+
957
+ # --- rubber-band selection on empty space ---
958
+ def mousePressEvent(self, e: QMouseEvent):
959
+ if e.button() == Qt.MouseButton.LeftButton:
960
+ local = e.position().toPoint()
961
+ # If click hits no child (shortcut), start rubber-band
962
+ if self.childAt(local) is None:
963
+ self._rubber_origin = local
964
+ self._rubber.setGeometry(QRect(self._rubber_origin, self._rubber_origin))
965
+ self._rubber.show()
966
+ # if no add/toggle mods, clear selection first
967
+ if not (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)):
968
+ self._mgr.clear_selection()
969
+ self.setFocus()
970
+ e.accept()
971
+ return
972
+ super().mousePressEvent(e)
973
+
974
+ def mouseMoveEvent(self, e: QMouseEvent):
975
+ if self._rubber.isVisible() and self._rubber_origin is not None:
976
+ rect = QRect(self._rubber_origin, e.position().toPoint()).normalized()
977
+ self._rubber.setGeometry(rect)
978
+ e.accept()
979
+ return
980
+ super().mouseMoveEvent(e)
981
+
982
+ def mouseReleaseEvent(self, e: QMouseEvent):
983
+ if self._rubber.isVisible() and self._rubber_origin is not None:
984
+ rect = QRect(self._rubber_origin, e.position().toPoint()).normalized()
985
+ mode = "add" if (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)) else "replace"
986
+ self._rubber.hide()
987
+ self._rubber_origin = None
988
+ self._mgr.select_in_rect(rect, mode=mode)
989
+ e.accept()
990
+ return
991
+ super().mouseReleaseEvent(e)
992
+
993
+ # --- keyboard: Delete / Backspace / Ctrl+A ---
994
+ def keyPressEvent(self, e: QKeyEvent):
995
+ if e.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
996
+ self._mgr.delete_selected()
997
+ e.accept(); return
998
+ if e.key() == Qt.Key.Key_A and (e.modifiers() & Qt.KeyboardModifier.ControlModifier):
999
+ self._mgr.select_in_rect(self.rect(), mode="replace")
1000
+ e.accept(); return
1001
+ super().keyPressEvent(e)
1002
+
1003
+ def dragEnterEvent(self, e):
1004
+ md = e.mimeData()
1005
+ if md.hasFormat(MIME_ACTION) or md.hasFormat(MIME_CMD) or self._md_has_openable_urls(md):
1006
+ self.raise_()
1007
+ e.acceptProposedAction()
1008
+ else:
1009
+ e.ignore()
1010
+
1011
+ def _top_subwindow_at(self, vp_pos: QPoint) -> QMdiSubWindow | None:
1012
+ # Use the correct enum for PyQt6, fall back gracefully if unavailable
1013
+ try:
1014
+ order_enum = QMdiArea.WindowOrder # PyQt6
1015
+ swlist = self._mgr.mdi.subWindowList(order_enum.StackingOrder)
1016
+ except Exception:
1017
+ # Fallback for older bindings
1018
+ swlist = self._mgr.mdi.subWindowList()
1019
+
1020
+ # Iterate from front-most to back-most (StackingOrder is typically back->front)
1021
+ for sw in reversed(swlist):
1022
+ if not sw.isVisible():
1023
+ continue
1024
+ # QMdiSubWindow geometry is in the viewport's coordinate space
1025
+ if sw.geometry().contains(vp_pos):
1026
+ return sw
1027
+ return None
1028
+
1029
+ def _forward_command_drop(self, e) -> bool:
1030
+ from PyQt6.QtWidgets import QApplication
1031
+ md = e.mimeData()
1032
+ if not md.hasFormat(MIME_CMD):
1033
+ return False
1034
+ sw = self._top_subwindow_at(e.position().toPoint())
1035
+ if sw is None:
1036
+ print("[ShortcutCanvas] _forward_command_drop: no subwindow under cursor", flush=True)
1037
+ QApplication.processEvents()
1038
+ return False
1039
+ try:
1040
+ raw = bytes(md.data(MIME_CMD))
1041
+ payload = _unpack_cmd_payload(raw) # your existing helper
1042
+ print(f"[ShortcutCanvas] _forward_command_drop → subwin={sw}, payload={payload!r}", flush=True)
1043
+ QApplication.processEvents()
1044
+ except Exception as ex:
1045
+ print(f"[ShortcutCanvas] _forward_command_drop: failed to unpack payload: {ex!r}", flush=True)
1046
+ QApplication.processEvents()
1047
+ return False
1048
+ self._mgr.apply_command_to_subwindow(sw, payload)
1049
+ e.acceptProposedAction()
1050
+ return True
1051
+
1052
+
1053
+ def dragMoveEvent(self, e):
1054
+ if e.mimeData().hasFormat(MIME_ACTION) or e.mimeData().hasFormat(MIME_CMD) or self._md_has_openable_urls(e.mimeData()):
1055
+ e.acceptProposedAction()
1056
+ else:
1057
+ e.ignore()
1058
+
1059
+ def dragLeaveEvent(self, e):
1060
+ self.lower() # restore
1061
+ super().dragLeaveEvent(e)
1062
+
1063
+ def dropEvent(self, e):
1064
+ md = e.mimeData()
1065
+
1066
+ # 1) route function/preset drops to the front-most subwindow under cursor
1067
+ if self._forward_command_drop(e):
1068
+ self.lower()
1069
+ return
1070
+
1071
+ # 2) command-only drops (no MIME_ACTION) → create a shortcut with preset
1072
+ # This is used by History Explorer Alt+drag.
1073
+ if md.hasFormat(MIME_CMD) and not md.hasFormat(MIME_ACTION):
1074
+ try:
1075
+ raw = bytes(md.data(MIME_CMD))
1076
+ payload = _unpack_cmd_payload(raw)
1077
+ except Exception:
1078
+ payload = None
1079
+
1080
+ if isinstance(payload, dict) and payload.get("command_id"):
1081
+ self._mgr.add_shortcut_from_payload(payload, e.position().toPoint())
1082
+ e.acceptProposedAction()
1083
+ self.lower()
1084
+ return
1085
+
1086
+ # 3) desktop shortcut creation (MIME_ACTION) → create a button (no preset)
1087
+ if md.hasFormat(MIME_ACTION):
1088
+ act_id = bytes(md.data(MIME_ACTION)).decode("utf-8")
1089
+ self._mgr.add_shortcut(act_id, e.position().toPoint())
1090
+ e.acceptProposedAction()
1091
+ self.lower()
1092
+ return
1093
+
1094
+ # 4) File / folder open (unchanged)
1095
+ if self._md_has_openable_urls(md):
1096
+ paths = self._collect_openable_files_from_urls(md)
1097
+ if paths:
1098
+ opener = getattr(self._mgr.mw, "_handle_external_file_drop", None)
1099
+ if callable(opener):
1100
+ opener(paths)
1101
+ else:
1102
+ dm = getattr(self._mgr.mw, "docman", None)
1103
+ if dm and hasattr(dm, "open_files") and callable(dm.open_files):
1104
+ docs = dm.open_files(paths)
1105
+ try:
1106
+ for d in (docs or []):
1107
+ self._mgr.mw._spawn_subwindow_for(d)
1108
+ except Exception:
1109
+ pass
1110
+ elif dm and hasattr(dm, "open_path") and callable(dm.open_path):
1111
+ for p in paths:
1112
+ doc = dm.open_path(p)
1113
+ if doc is not None:
1114
+ self._mgr.mw._spawn_subwindow_for(doc)
1115
+ e.acceptProposedAction()
1116
+ self.lower()
1117
+ return
1118
+ self.lower()
1119
+ e.ignore()
1120
+
1121
+ def contextMenuEvent(self, e):
1122
+ menu = QMenu(self)
1123
+ has_sel = bool(self._mgr.selected)
1124
+ a_del = menu.addAction(self.tr("Delete Selected"), self._mgr.delete_selected); a_del.setEnabled(has_sel)
1125
+ a_clr = menu.addAction(self.tr("Clear Selection"), self._mgr.clear_selection); a_clr.setEnabled(has_sel)
1126
+ menu.addSeparator()
1127
+ a_vb = menu.addAction(self.tr("View Bundles…"), lambda: _open_view_bundles_from_canvas(self))
1128
+ a_fb = menu.addAction(self.tr("Function Bundles…"), lambda: _open_function_bundles_from_canvas(self))
1129
+ menu.exec(e.globalPos())
1130
+
1131
+
1132
+ def mouseDoubleClickEvent(self, e):
1133
+ # If user double-clicks empty canvas area, forward to MDI's handler
1134
+ if e.button() == Qt.MouseButton.LeftButton:
1135
+ local = e.position().toPoint()
1136
+ if self.childAt(local) is None:
1137
+ try:
1138
+ # Reuse your existing connection: mdi.backgroundDoubleClicked -> open_files
1139
+ self._mgr.mdi.backgroundDoubleClicked.emit()
1140
+ except Exception:
1141
+ pass
1142
+ e.accept()
1143
+ return
1144
+ super().mouseDoubleClickEvent(e)
1145
+
1146
+ def _is_openable_path(self, path: str) -> bool:
1147
+ return path.lower().endswith(OPENABLE_ENDINGS)
1148
+
1149
+ def _md_has_openable_urls(self, md) -> bool:
1150
+ if not md.hasUrls():
1151
+ return False
1152
+ for u in md.urls():
1153
+ if not u.isLocalFile():
1154
+ continue
1155
+ p = u.toLocalFile()
1156
+ if os.path.isdir(p):
1157
+ return True # we'll scan it on drop
1158
+ if self._is_openable_path(p):
1159
+ return True
1160
+ return False
1161
+
1162
+ def _collect_openable_files_from_urls(self, md) -> list[str]:
1163
+ files: list[str] = []
1164
+ if not md.hasUrls():
1165
+ return files
1166
+ for u in md.urls():
1167
+ if not u.isLocalFile():
1168
+ continue
1169
+ p = u.toLocalFile()
1170
+ if os.path.isdir(p):
1171
+ # recurse folder for matching files
1172
+ for root, _, names in os.walk(p):
1173
+ for name in names:
1174
+ fp = os.path.join(root, name)
1175
+ if self._is_openable_path(fp):
1176
+ files.append(fp)
1177
+ else:
1178
+ if self._is_openable_path(p):
1179
+ files.append(p)
1180
+ return files
1181
+
1182
+
1183
+ class ShortcutManager:
1184
+ def __init__(self, mdi_area, main_window):
1185
+ # mdi_area should be your QMdiArea; we attach to its viewport
1186
+ self.mdi = mdi_area
1187
+ self.mw = main_window
1188
+ self.registry: Dict[str, QAction] = {}
1189
+ self.canvas = ShortcutCanvas(self, self.mdi.viewport())
1190
+ self.canvas.lower() # keep below subwindows (raise() if you want pinned-on-top)
1191
+ self.canvas.show()
1192
+ self.widgets: Dict[str, ShortcutButton] = {}
1193
+ self.settings = QSettings() # shared settings store for positions + presets
1194
+ self.selected: set[str] = set() # ← set of shortcut_ids
1195
+
1196
+ # ---- registry ----
1197
+ def register_action(self, command_id: str, action: QAction):
1198
+ action.setProperty("command_id", command_id)
1199
+ if not action.objectName():
1200
+ action.setObjectName(command_id)
1201
+
1202
+ # Ensure action shortcuts work even if focus is in child widgets / MDI
1203
+ action.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
1204
+
1205
+ self.registry[command_id] = action
1206
+
1207
+ # Apply saved keybind if present
1208
+ kb = self._load_keybinds().get(command_id)
1209
+ if kb:
1210
+ action.setShortcut(QKeySequence(kb))
1211
+
1212
+ def find_keybind_conflicts(self) -> dict[str, list[str]]:
1213
+ # returns {"Ctrl+Alt+K": ["script:...", "stat_stretch"], ...}
1214
+ conflicts = {}
1215
+ for cid, act in self.registry.items():
1216
+ ks = act.shortcut().toString()
1217
+ if not ks:
1218
+ continue
1219
+ conflicts.setdefault(ks, []).append(cid)
1220
+ return {k:v for k,v in conflicts.items() if len(v) > 1}
1221
+
1222
+ def trigger(self, command_id: str):
1223
+ act = self.registry.get(command_id)
1224
+ if act:
1225
+ act.trigger()
1226
+
1227
+ def _on_widget_destroyed(self, sid: str):
1228
+ # Called from QObject.destroyed — never touch the widget, just clean maps
1229
+ self.widgets.pop(sid, None)
1230
+ self.selected.discard(sid)
1231
+
1232
+ def _collect_live_items(self) -> list[dict]:
1233
+ """
1234
+ Collect visible shortcut widgets into a serializable list.
1235
+
1236
+ For each shortcut we also try to inline its preset (if any) so that
1237
+ function bundles and other per-instance presets can be exported.
1238
+ """
1239
+ data = []
1240
+ for sid, w in list(self.widgets.items()):
1241
+ if _is_dead(w):
1242
+ self.widgets.pop(sid, None)
1243
+ self.selected.discard(sid)
1244
+ continue
1245
+ try:
1246
+ if not w.isVisible():
1247
+ continue
1248
+
1249
+ p = w.pos()
1250
+ item = {
1251
+ "id": sid,
1252
+ "command_id": getattr(w, "command_id", None),
1253
+ "label": w.text(),
1254
+ "x": int(p.x()),
1255
+ "y": int(p.y()),
1256
+ }
1257
+
1258
+ # Try to inline per-instance preset
1259
+ preset = None
1260
+ try:
1261
+ if hasattr(w, "_load_preset"):
1262
+ preset = w._load_preset()
1263
+ except Exception:
1264
+ preset = None
1265
+
1266
+ if isinstance(preset, dict) and preset:
1267
+ item["preset"] = preset
1268
+
1269
+ data.append(item)
1270
+ except RuntimeError:
1271
+ self.widgets.pop(sid, None)
1272
+ self.selected.discard(sid)
1273
+
1274
+ # Debug: summarize what we collected
1275
+ try:
1276
+ summary = []
1277
+ for it in data:
1278
+ cid = it.get("command_id")
1279
+ has_preset = "preset" in it
1280
+ summary.append(f"{cid!r} preset={has_preset}")
1281
+ self._debug(f"_collect_live_items: {len(data)} item(s): " + ", ".join(summary))
1282
+ except Exception:
1283
+ pass
1284
+
1285
+ return data
1286
+
1287
+ def _export_function_bundles_for_shortcuts(self) -> dict | None:
1288
+ """
1289
+ Ask pro.function_bundle for function bundle defs + chip layout
1290
+ so we can embed them into the .sass export.
1291
+ """
1292
+ try:
1293
+ from setiastro.saspro.function_bundle import export_function_bundles_payload
1294
+ except Exception:
1295
+ return None
1296
+ try:
1297
+ fb = export_function_bundles_payload()
1298
+ if isinstance(fb, dict):
1299
+ return fb
1300
+ except Exception:
1301
+ pass
1302
+ return None
1303
+
1304
+ def _import_function_bundles_for_shortcuts(self, payload: dict | None, replace_existing: bool):
1305
+ """
1306
+ Restore function bundle defs + chips after a .sass import.
1307
+ """
1308
+ if not isinstance(payload, dict):
1309
+ return
1310
+ try:
1311
+ from setiastro.saspro.function_bundle import import_function_bundles_payload
1312
+ except Exception:
1313
+ return
1314
+ try:
1315
+ mw = getattr(self, "mw", None)
1316
+ import_function_bundles_payload(payload, mw, replace_existing=replace_existing)
1317
+ except Exception:
1318
+ pass
1319
+
1320
+
1321
+ def _debug(self, msg: str):
1322
+ """Best-effort debug logging for shortcuts."""
1323
+ try:
1324
+ # Prefer main window log if available
1325
+ if hasattr(self.mw, "_log") and callable(self.mw._log):
1326
+ self.mw._log(f"[Shortcuts] {msg}")
1327
+ return
1328
+ except Exception:
1329
+ pass
1330
+ # Fallback to stdout
1331
+ try:
1332
+ print(f"[Shortcuts] {msg}")
1333
+ except Exception:
1334
+ pass
1335
+
1336
+
1337
+ # ---------- New: export/import ----------
1338
+ def export_to_file(self, file_path: str) -> tuple[bool, str]:
1339
+ try:
1340
+ fp = self._ensure_ext(file_path, ".sass")
1341
+
1342
+ items = self._collect_live_items()
1343
+ payload = {
1344
+ "kind": SASS_KIND,
1345
+ "version": SASS_VER,
1346
+ "exported_at": int(time.time()),
1347
+ "items": items,
1348
+ }
1349
+
1350
+ # NEW: include function bundles + chip layout if available
1351
+ fb_payload = self._export_function_bundles_for_shortcuts()
1352
+ if fb_payload is not None:
1353
+ payload["function_bundles"] = fb_payload
1354
+
1355
+ Path(fp).write_text(json.dumps(payload, indent=2), encoding="utf-8")
1356
+
1357
+ # optional debug
1358
+ try:
1359
+ self._debug(f"export_to_file → {fp}, shortcuts={len(items)}, "
1360
+ f"fb_bundles={len((fb_payload or {}).get('bundles', []))}")
1361
+ except Exception:
1362
+ pass
1363
+
1364
+ return True, fp
1365
+ except Exception as e:
1366
+ try:
1367
+ self._debug(f"export_to_file FAILED: {e}")
1368
+ except Exception:
1369
+ pass
1370
+ return False, str(e)
1371
+
1372
+ def import_from_file(self, file_path: str, *, replace_existing: bool = False) -> tuple[bool, str]:
1373
+ try:
1374
+ txt = Path(file_path).read_text(encoding="utf-8")
1375
+ obj = json.loads(txt)
1376
+
1377
+ fb_payload = None
1378
+
1379
+ # Basic validation (accepts legacy raw arrays too)
1380
+ if isinstance(obj, dict) and obj.get("kind") == SASS_KIND:
1381
+ items = obj.get("items", [])
1382
+ fb_payload = obj.get("function_bundles")
1383
+ elif isinstance(obj, list):
1384
+ # legacy: straight array of items (no function bundle info)
1385
+ items = obj
1386
+ else:
1387
+ return False, "File is not a SAS shortcuts file."
1388
+
1389
+ # optional debug
1390
+ try:
1391
+ self._debug(
1392
+ f"import_from_file ← {file_path}, items={len(items)}, "
1393
+ f"has_fb={isinstance(fb_payload, dict)}, replace_existing={replace_existing}"
1394
+ )
1395
+ except Exception:
1396
+ pass
1397
+
1398
+ if replace_existing:
1399
+ self.clear() # clears both UI + settings keys, keeps manager ready
1400
+
1401
+ # Build widgets (shortcuts) as before
1402
+ for it in items:
1403
+ cid = it.get("command_id")
1404
+ if not cid:
1405
+ continue
1406
+ sid = it.get("id") or uuid.uuid4().hex
1407
+ x = int(it.get("x", 10))
1408
+ y = int(it.get("y", 10))
1409
+ label = it.get("label") or self._default_label_for(cid)
1410
+
1411
+ self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
1412
+
1413
+ # If you also inline per-instance presets for normal shortcuts,
1414
+ # you can restore them here as well (omitted here for brevity).
1415
+
1416
+ # Persist shortcuts to QSettings
1417
+ self.save_shortcuts()
1418
+
1419
+ # NEW: Restore function bundle definitions + chips
1420
+ self._import_function_bundles_for_shortcuts(fb_payload, replace_existing=replace_existing)
1421
+
1422
+ return True, "OK"
1423
+ except Exception as e:
1424
+ try:
1425
+ self._debug(f"import_from_file FAILED: {e}")
1426
+ except Exception:
1427
+ pass
1428
+ return False, str(e)
1429
+
1430
+ def _icon_for_command(self, command_id: str, act: QAction | None) -> QIcon:
1431
+ # 1) Prefer the QAction icon (works for built-in tools and scripts if set)
1432
+ if act is not None:
1433
+ try:
1434
+ ico = act.icon()
1435
+ if ico is not None and not ico.isNull():
1436
+ return ico
1437
+ except Exception:
1438
+ pass
1439
+
1440
+ # 2) Optional: if the action carries a script_icon_path property, use it
1441
+ try:
1442
+ p = act.property("script_icon_path")
1443
+ if isinstance(p, str) and p.strip() and Path(p).exists():
1444
+ return QIcon(p.strip())
1445
+ except Exception:
1446
+ pass
1447
+
1448
+ # 3) Fallback for scripts: use the generic SCRIPT icon
1449
+ if isinstance(command_id, str) and command_id.startswith("script:"):
1450
+ try:
1451
+ ic = _get_icons()
1452
+ if ic is not None and hasattr(ic, "SCRIPT"):
1453
+ v = ic.SCRIPT
1454
+ return v if isinstance(v, QIcon) else QIcon(str(v))
1455
+ except Exception:
1456
+ pass
1457
+
1458
+ return QIcon()
1459
+
1460
+
1461
+ # ---------- utils ----------
1462
+ def _ensure_ext(self, path: str, ext: str) -> str:
1463
+ p = Path(path)
1464
+ if p.suffix.lower() != ext.lower():
1465
+ p = p.with_suffix(ext)
1466
+ return str(p)
1467
+
1468
+ # ---- CRUD for shortcuts --------------------------------------------
1469
+ def _default_label_for(self, command_id: str) -> str:
1470
+ act = self.registry.get(command_id)
1471
+ if not act:
1472
+ return command_id
1473
+ return (act.text() or act.toolTip() or command_id).strip() or command_id
1474
+
1475
+ def add_shortcut(self,
1476
+ command_id: str,
1477
+ pos: QPoint,
1478
+ *,
1479
+ label: Optional[str] = None,
1480
+ shortcut_id: Optional[str] = None):
1481
+ """
1482
+ Always creates a NEW instance (multiple per command_id allowed).
1483
+ """
1484
+ act = self.registry.get(command_id)
1485
+ if not act:
1486
+ return
1487
+
1488
+ sid = shortcut_id or uuid.uuid4().hex
1489
+ lbl = (label or self._default_label_for(command_id)).strip() or command_id
1490
+
1491
+ ico = self._icon_for_command(command_id, act)
1492
+ w = ShortcutButton(self, sid, command_id, ico, lbl, self.canvas)
1493
+ w.adjustSize()
1494
+ w.move(pos)
1495
+ w.show()
1496
+
1497
+ # when the C++ object dies, clean maps using the SID
1498
+ w.destroyed.connect(lambda _=None, sid=sid: self._on_widget_destroyed(sid))
1499
+
1500
+ self.widgets[sid] = w
1501
+ self.save_shortcuts()
1502
+
1503
+ def add_shortcut_from_payload(self, payload: dict, pos: QPoint):
1504
+ """
1505
+ Create a desktop shortcut from a full command payload
1506
+ (e.g. drag from History Explorer with command_id + preset).
1507
+ """
1508
+ if not isinstance(payload, dict):
1509
+ return
1510
+
1511
+ cid = payload.get("command_id") or payload.get("cid")
1512
+ if not isinstance(cid, str) or not cid:
1513
+ return
1514
+
1515
+ preset = payload.get("preset") or {}
1516
+ if not isinstance(preset, dict):
1517
+ try:
1518
+ preset = dict(preset)
1519
+ except Exception:
1520
+ preset = {}
1521
+
1522
+ # Normal shortcut creation
1523
+ sid = uuid.uuid4().hex
1524
+ label = self._default_label_for(cid)
1525
+ self.add_shortcut(cid, pos, label=label, shortcut_id=sid)
1526
+
1527
+ # Attach preset at instance-level (same mechanism as context menu)
1528
+ w = self.widgets.get(sid)
1529
+ if w and not _is_dead(w):
1530
+ try:
1531
+ w._save_preset(preset)
1532
+ except Exception:
1533
+ pass
1534
+
1535
+ # Persist layout + presets
1536
+ self.save_shortcuts()
1537
+
1538
+
1539
+ def update_label(self, shortcut_id: str, new_label: str):
1540
+ w = self.widgets.get(shortcut_id)
1541
+ if w and not _is_dead(w):
1542
+ w.setText(new_label.strip()) # in case caller didn't already
1543
+ self.save_shortcuts()
1544
+
1545
+
1546
+ # ---- persistence (QSettings JSON blob) ----
1547
+ def save_shortcuts(self):
1548
+ data = []
1549
+ for sid, w in list(self.widgets.items()):
1550
+ if _is_dead(w):
1551
+ self.widgets.pop(sid, None)
1552
+ self.selected.discard(sid)
1553
+ continue
1554
+ try:
1555
+ if not w.isVisible():
1556
+ continue
1557
+ p = w.pos()
1558
+ data.append({
1559
+ "id": sid,
1560
+ "command_id": w.command_id,
1561
+ "label": w.text(),
1562
+ "x": p.x(),
1563
+ "y": p.y(),
1564
+ })
1565
+ except RuntimeError:
1566
+ self.widgets.pop(sid, None)
1567
+ self.selected.discard(sid)
1568
+
1569
+ # Save new format and remove legacy
1570
+ self.settings.setValue(SET_KEY_V2, json.dumps(data))
1571
+ self.settings.remove(SET_KEY_V1)
1572
+ self.settings.sync()
1573
+
1574
+ def load_shortcuts(self):
1575
+ # try v2 first
1576
+ raw_v2 = self.settings.value(SET_KEY_V2, "", type=str) or ""
1577
+ if raw_v2:
1578
+ try:
1579
+ arr = json.loads(raw_v2)
1580
+ for entry in arr:
1581
+ sid = entry.get("id") or uuid.uuid4().hex
1582
+ cid = entry.get("command_id")
1583
+ x = int(entry.get("x", 10))
1584
+ y = int(entry.get("y", 10))
1585
+ label = entry.get("label") or self._default_label_for(cid)
1586
+ self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
1587
+ return
1588
+ except Exception as e:
1589
+ try:
1590
+ self.mw._log(f"Shortcuts v2: failed to load ({e})")
1591
+ except Exception:
1592
+ pass
1593
+
1594
+ # migrate v1 (positions only)
1595
+ raw_v1 = self.settings.value(SET_KEY_V1, "", type=str) or ""
1596
+ if not raw_v1:
1597
+ return
1598
+ try:
1599
+ arr = json.loads(raw_v1)
1600
+ for entry in arr:
1601
+ cid = entry.get("id") or entry.get("command_id") # old key was "id" = command_id
1602
+ x = int(entry.get("x", 10))
1603
+ y = int(entry.get("y", 10))
1604
+ # each old entry becomes its own instance
1605
+ sid = uuid.uuid4().hex
1606
+ label = self._default_label_for(cid)
1607
+ self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
1608
+ # after migrating, persist as v2
1609
+ self.save_shortcuts()
1610
+ except Exception as e:
1611
+ try:
1612
+ self.mw._log(f"Shortcuts v1: failed to migrate ({e})")
1613
+ except Exception:
1614
+ pass
1615
+
1616
+
1617
+ def _load_keybinds(self) -> dict:
1618
+ raw = self.settings.value(KEYBINDS_KEY, "", type=str) or ""
1619
+ if not raw:
1620
+ return {}
1621
+ try:
1622
+ obj = json.loads(raw)
1623
+ return obj if isinstance(obj, dict) else {}
1624
+ except Exception:
1625
+ return {}
1626
+
1627
+ def _save_keybinds(self, d: dict):
1628
+ self.settings.setValue(KEYBINDS_KEY, json.dumps(d))
1629
+ self.settings.sync()
1630
+
1631
+ def set_keybind(self, command_id: str, keyseq: str | None):
1632
+ """
1633
+ keyseq: e.g. "Ctrl+Alt+K". If None/empty => clear binding.
1634
+ Applies immediately if action is registered.
1635
+ """
1636
+ d = self._load_keybinds()
1637
+ if keyseq and keyseq.strip():
1638
+ d[command_id] = keyseq.strip()
1639
+ else:
1640
+ d.pop(command_id, None)
1641
+ self._save_keybinds(d)
1642
+
1643
+ act = self.registry.get(command_id)
1644
+ if act is not None:
1645
+ if keyseq and keyseq.strip():
1646
+ act.setShortcut(QKeySequence(keyseq.strip()))
1647
+ act.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
1648
+ else:
1649
+ act.setShortcut(QKeySequence())
1650
+
1651
+ def apply_command_to_subwindow(self, subwin, payload):
1652
+ """Apply a dragged command (or bundle) to the specific subwindow."""
1653
+ from PyQt6.QtWidgets import QApplication
1654
+
1655
+ # --- normalize payload to a dict ---
1656
+ if isinstance(payload, (bytes, bytearray)):
1657
+ try:
1658
+ payload = json.loads(payload.decode("utf-8"))
1659
+ except Exception:
1660
+ print("[Shortcuts] apply_command_to_subwindow: invalid bytes payload", flush=True)
1661
+ QApplication.processEvents()
1662
+ return
1663
+ if not isinstance(payload, dict):
1664
+ print(f"[Shortcuts] apply_command_to_subwindow: non-dict payload {type(payload)}", flush=True)
1665
+ QApplication.processEvents()
1666
+ return
1667
+
1668
+ print(f"[Shortcuts] apply_command_to_subwindow: subwin={subwin}, payload={payload!r}", flush=True)
1669
+ QApplication.processEvents()
1670
+
1671
+ # --- flatten accidental nesting:
1672
+ cid = payload.get("command_id")
1673
+ if isinstance(cid, dict):
1674
+ payload = cid
1675
+ cid = payload.get("command_id")
1676
+
1677
+ if not isinstance(cid, str) and isinstance(payload.get("command_id"), dict):
1678
+ payload = payload["command_id"]
1679
+ cid = payload.get("command_id")
1680
+
1681
+ if not isinstance(cid, str) or not cid:
1682
+ print("[Shortcuts] apply_command_to_subwindow: no valid command_id", flush=True)
1683
+ QApplication.processEvents()
1684
+ return
1685
+
1686
+ # --- function bundle handling ---
1687
+ if cid == "function_bundle":
1688
+ steps = payload.get("steps")
1689
+ inherit_target = bool(payload.get("inherit_target", True))
1690
+ print(f"[Shortcuts] function_bundle: {len(steps or [])} step(s), inherit_target={inherit_target}", flush=True)
1691
+ QApplication.processEvents()
1692
+
1693
+ # If explicit steps are present (chip DnD / history replay payload),
1694
+ # run them INLINE via the normal command path (same as FunctionBundleDialog).
1695
+ if isinstance(steps, list) and steps:
1696
+ for i, st in enumerate(steps, start=1):
1697
+ try:
1698
+ scid = st.get("command_id")
1699
+ except Exception:
1700
+ scid = None
1701
+ print(f"[Shortcuts] inline step {i}/{len(steps)} → {scid!r}", flush=True)
1702
+ QApplication.processEvents()
1703
+ # Reuse the same target subwindow for each step
1704
+ self.apply_command_to_subwindow(subwin, st)
1705
+ return
1706
+
1707
+ # No inline steps → this is a true 'function_bundle' command (e.g. from Scripts),
1708
+ # so delegate to the central executor.
1709
+ try:
1710
+ from setiastro.saspro.function_bundle import run_function_bundle_command
1711
+ print("[Shortcuts] function_bundle: using run_function_bundle_command (no inline steps)", flush=True)
1712
+ QApplication.processEvents()
1713
+
1714
+ try:
1715
+ self.mdi.setActiveSubWindow(subwin)
1716
+ except Exception:
1717
+ pass
1718
+
1719
+ # Pass the whole payload as cfg so things like bundle_key, etc., are available.
1720
+ cfg = dict(payload)
1721
+ try:
1722
+ run_function_bundle_command(self.mw, preset=payload.get("preset") or None, cfg=cfg)
1723
+ except TypeError:
1724
+ # older signature: (ctx, cfg)
1725
+ run_function_bundle_command(self.mw, cfg)
1726
+ print("[Shortcuts] function_bundle: run_function_bundle_command finished", flush=True)
1727
+ QApplication.processEvents()
1728
+ return
1729
+ except Exception as ex:
1730
+ print(f"[Shortcuts] function_bundle: FAILED in central executor: {ex!r}", flush=True)
1731
+ QApplication.processEvents()
1732
+ return
1733
+
1734
+ # --- primary path (unchanged) ---
1735
+ mw = self.mw
1736
+ try:
1737
+ if hasattr(mw, "_handle_command_drop"):
1738
+ print(f"[Shortcuts] forwarding cid={cid!r} to _handle_command_drop", flush=True)
1739
+ QApplication.processEvents()
1740
+ mw._handle_command_drop(payload, target_sw=subwin)
1741
+ return
1742
+ except Exception as ex:
1743
+ print(f"[Shortcuts] _handle_command_drop raised: {ex!r}, falling through", flush=True)
1744
+ QApplication.processEvents()
1745
+
1746
+ # --- secondary paths (unchanged) ---
1747
+ w = getattr(subwin, "widget", None)
1748
+ target = w() if callable(w) else w
1749
+ preset = payload.get("preset") or {}
1750
+
1751
+ if hasattr(target, "apply_command"):
1752
+ print(f"[Shortcuts] target.apply_command for cid={cid!r}", flush=True)
1753
+ QApplication.processEvents()
1754
+ target.apply_command(cid, preset)
1755
+ return
1756
+ if hasattr(mw, "apply_command_to_view"):
1757
+ print(f"[Shortcuts] mw.apply_command_to_view for cid={cid!r}", flush=True)
1758
+ QApplication.processEvents()
1759
+ mw.apply_command_to_view(target, cid, preset)
1760
+ return
1761
+ if hasattr(mw, "run_command"):
1762
+ print(f"[Shortcuts] mw.run_command for cid={cid!r}", flush=True)
1763
+ QApplication.processEvents()
1764
+ mw.run_command(cid, preset, view=target)
1765
+ return
1766
+
1767
+ print(f"[Shortcuts] fallback QAction trigger for cid={cid!r}", flush=True)
1768
+ QApplication.processEvents()
1769
+ self.mdi.setActiveSubWindow(subwin)
1770
+ act = self.registry.get(cid if isinstance(cid, str) else str(cid))
1771
+ if act:
1772
+ act.trigger()
1773
+
1774
+
1775
+ # ---------- selection ----------
1776
+ def _apply_sel_visual(self, sid: str, on: bool):
1777
+ w = self.widgets.get(sid)
1778
+ if _is_dead(w):
1779
+ # Clean up any stale references
1780
+ self.widgets.pop(sid, None)
1781
+ self.selected.discard(sid)
1782
+ return
1783
+ try:
1784
+ if on:
1785
+ w.setStyleSheet("QToolButton { border: 2px solid #4da3ff; border-radius: 6px; padding: 2px; }")
1786
+ else:
1787
+ w.setStyleSheet("")
1788
+ except RuntimeError:
1789
+ # C++ object died between get() and call
1790
+ self.widgets.pop(sid, None)
1791
+ self.selected.discard(sid)
1792
+
1793
+ def select_only(self, sid: str):
1794
+ self.clear_selection()
1795
+ self.selected.add(sid)
1796
+ self._apply_sel_visual(sid, True)
1797
+
1798
+ def toggle_select(self, sid: str):
1799
+ if sid in self.selected:
1800
+ self.selected.remove(sid)
1801
+ self._apply_sel_visual(sid, False)
1802
+ else:
1803
+ self.selected.add(sid)
1804
+ self._apply_sel_visual(sid, True)
1805
+
1806
+ def select_in_rect(self, rect: QRect, *, mode: str = "replace"):
1807
+ if mode == "replace":
1808
+ self.clear_selection()
1809
+ for sid, w in list(self.widgets.items()):
1810
+ if _is_dead(w):
1811
+ self.widgets.pop(sid, None)
1812
+ self.selected.discard(sid)
1813
+ continue
1814
+ if rect.intersects(w.geometry()):
1815
+ if sid not in self.selected:
1816
+ self.selected.add(sid)
1817
+ self._apply_sel_visual(sid, True)
1818
+
1819
+ def selected_widgets(self):
1820
+ out = []
1821
+ for sid in list(self.selected):
1822
+ w = self.widgets.get(sid)
1823
+ if _is_dead(w):
1824
+ self.widgets.pop(sid, None)
1825
+ self.selected.discard(sid)
1826
+ continue
1827
+ out.append(w)
1828
+ return out
1829
+
1830
+ def clear_selection(self):
1831
+ """Clear current selection highlight without deleting shortcuts."""
1832
+ # Remove highlight from all currently selected items
1833
+ for sid in list(self.selected):
1834
+ try:
1835
+ self._apply_sel_visual(sid, False)
1836
+ except Exception:
1837
+ pass
1838
+ self.selected.clear()
1839
+
1840
+ # Nudge repaint (optional but helps)
1841
+ try:
1842
+ self.canvas.update()
1843
+ except Exception:
1844
+ pass
1845
+
1846
+
1847
+ def clear(self):
1848
+ for sid, w in list(self.widgets.items()):
1849
+ try:
1850
+ if not _is_dead(w):
1851
+ w.hide()
1852
+ try:
1853
+ w.setParent(None) # ← detach from canvas immediately
1854
+ except Exception:
1855
+ pass
1856
+ w.deleteLater()
1857
+ except RuntimeError:
1858
+ pass
1859
+ self.widgets.clear()
1860
+ self.selected.clear()
1861
+ self.settings.setValue(SET_KEY_V2, "[]")
1862
+ self.settings.remove(SET_KEY_V1)
1863
+ self.settings.sync()
1864
+ try:
1865
+ self.canvas.update() # nudge repaint
1866
+ except Exception:
1867
+ pass
1868
+
1869
+
1870
+ # ---------- group move / delete ----------
1871
+ def _group_bounds(self) -> QRect:
1872
+ rect = None
1873
+ for w in self.selected_widgets():
1874
+ rect = w.geometry() if rect is None else rect.united(w.geometry())
1875
+ return rect if rect is not None else QRect()
1876
+
1877
+ def move_selected_by(self, dx: int, dy: int):
1878
+ if not self.selected:
1879
+ return
1880
+ # clamp whole group to canvas bounds so relative spacing stays intact
1881
+ group = self._group_bounds()
1882
+ vp = self.canvas.rect()
1883
+ min_dx = vp.left() - group.left()
1884
+ max_dx = vp.right() - group.right()
1885
+ min_dy = vp.top() - group.top()
1886
+ max_dy = vp.bottom()- group.bottom()
1887
+ dx = max(min_dx, min(dx, max_dx))
1888
+ dy = max(min_dy, min(dy, max_dy))
1889
+ if dx == 0 and dy == 0:
1890
+ return
1891
+ for w in self.selected_widgets():
1892
+ g = w.geometry()
1893
+ g.translate(dx, dy)
1894
+ w.setGeometry(g)
1895
+
1896
+ def delete_by_id(self, sid: str, *, persist: bool = True):
1897
+ self.selected.discard(sid)
1898
+ w = self.widgets.pop(sid, None)
1899
+ if not _is_dead(w):
1900
+ try:
1901
+ w.hide()
1902
+ except RuntimeError:
1903
+ pass
1904
+ try:
1905
+ w.deleteLater()
1906
+ except RuntimeError:
1907
+ pass
1908
+ if persist:
1909
+ self.save_shortcuts()
1910
+
1911
+ def delete_selected(self):
1912
+ # bulk delete, then persist once
1913
+ for sid in list(self.selected):
1914
+ self.delete_by_id(sid, persist=False)
1915
+ self.selected.clear()
1916
+ self.save_shortcuts()
1917
+
1918
+ def remove(self, sid: str):
1919
+ # legacy single-remove (kept for callers)
1920
+ self.delete_by_id(sid, persist=True)
1921
+
1922
+
1923
+ class _StatStretchPresetDialog(QDialog):
1924
+ def __init__(self, parent=None, initial: dict | None = None):
1925
+ super().__init__(parent)
1926
+ self.setWindowTitle("Statistical Stretch — Preset")
1927
+ init = dict(initial or {})
1928
+
1929
+ self.spin_target = QDoubleSpinBox()
1930
+ self.spin_target.setRange(0.0, 1.0); self.spin_target.setDecimals(3)
1931
+ self.spin_target.setSingleStep(0.01)
1932
+ self.spin_target.setValue(float(init.get("target_median", 0.25)))
1933
+
1934
+ self.chk_linked = QCheckBox("Linked RGB channels")
1935
+ self.chk_linked.setChecked(bool(init.get("linked", False)))
1936
+
1937
+ self.chk_normalize = QCheckBox("Normalize to [0..1]")
1938
+ self.chk_normalize.setChecked(bool(init.get("normalize", False)))
1939
+
1940
+ self.spin_curves = QDoubleSpinBox()
1941
+ self.spin_curves.setRange(0.0, 1.0); self.spin_curves.setDecimals(2)
1942
+ self.spin_curves.setSingleStep(0.05)
1943
+ self.spin_curves.setValue(float(init.get("curves_boost", 0.0 if not init.get("apply_curves") else 0.20)))
1944
+
1945
+ form = QFormLayout(self)
1946
+ form.addRow("Target median:", self.spin_target)
1947
+ form.addRow("", self.chk_linked)
1948
+ form.addRow("", self.chk_normalize)
1949
+ form.addRow("Curves boost (0–1):", self.spin_curves)
1950
+ form.addRow(QLabel("Curves are applied only if boost > 0."))
1951
+
1952
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1953
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
1954
+ form.addRow(btns)
1955
+
1956
+ def result_dict(self) -> dict:
1957
+ boost = float(self.spin_curves.value())
1958
+ return {
1959
+ "target_median": float(self.spin_target.value()),
1960
+ "linked": bool(self.chk_linked.isChecked()),
1961
+ "normalize": bool(self.chk_normalize.isChecked()),
1962
+ "apply_curves": bool(boost > 0.0),
1963
+ "curves_boost": boost,
1964
+ }
1965
+
1966
+
1967
+ class _StarStretchPresetDialog(QDialog):
1968
+ def __init__(self, parent=None, initial: dict | None = None):
1969
+ super().__init__(parent)
1970
+ self.setWindowTitle("Star Stretch — Preset")
1971
+ init = dict(initial or {})
1972
+
1973
+ self.spin_amount = QDoubleSpinBox()
1974
+ self.spin_amount.setRange(0.0, 8.0); self.spin_amount.setDecimals(2)
1975
+ self.spin_amount.setSingleStep(0.05)
1976
+ self.spin_amount.setValue(float(init.get("stretch_factor", 5.00)))
1977
+
1978
+ self.spin_sat = QDoubleSpinBox()
1979
+ self.spin_sat.setRange(0.0, 2.0); self.spin_sat.setDecimals(2)
1980
+ self.spin_sat.setSingleStep(0.05)
1981
+ self.spin_sat.setValue(float(init.get("color_boost", 1.00)))
1982
+
1983
+ self.chk_scnr = QCheckBox("Remove Green via SCNR")
1984
+ self.chk_scnr.setChecked(bool(init.get("scnr_green", False)))
1985
+
1986
+ form = QFormLayout(self)
1987
+ form.addRow("Stretch amount (0–8):", self.spin_amount)
1988
+ form.addRow("Color boost (0–2):", self.spin_sat)
1989
+ form.addRow("", self.chk_scnr)
1990
+
1991
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1992
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
1993
+ form.addRow(btns)
1994
+
1995
+ def result_dict(self) -> dict:
1996
+ return {
1997
+ "stretch_factor": float(self.spin_amount.value()), # 0..8
1998
+ "color_boost": float(self.spin_sat.value()), # 0..2
1999
+ "scnr_green": bool(self.chk_scnr.isChecked()),
2000
+ }
2001
+
2002
+ class _RemoveGreenPresetDialog(QDialog):
2003
+ def __init__(self, parent=None, initial: dict | None = None):
2004
+ super().__init__(parent)
2005
+ self.setWindowTitle("Remove Green — Preset")
2006
+ init = dict(initial or {})
2007
+
2008
+ # Local labels so there’s no external dependency.
2009
+ MODE_LABELS = {
2010
+ "avg": "Average neutral (G → min(avg(R,B), G))",
2011
+ "max": "Average neutral MAX (G → min(max(R,B), G))",
2012
+ "min": "Average neutral MIN (G → min(min(R,B), G))",
2013
+ }
2014
+ MODE_INDEX = {"avg": 0, "max": 1, "min": 2}
2015
+
2016
+ # Amount
2017
+ self.spin_amount = QDoubleSpinBox()
2018
+ self.spin_amount.setRange(0.0, 1.0)
2019
+ self.spin_amount.setDecimals(2)
2020
+ self.spin_amount.setSingleStep(0.05)
2021
+ self.spin_amount.setValue(float(init.get("amount", 1.00))) # default full SCNR
2022
+
2023
+ # Mode
2024
+ self.combo_mode = QComboBox()
2025
+ self.combo_mode.addItem(MODE_LABELS["avg"], userData="avg")
2026
+ self.combo_mode.addItem(MODE_LABELS["max"], userData="max")
2027
+ self.combo_mode.addItem(MODE_LABELS["min"], userData="min")
2028
+ init_mode = str(init.get("mode", init.get("neutral_mode", "avg"))).lower()
2029
+ self.combo_mode.setCurrentIndex(MODE_INDEX.get(init_mode, 0))
2030
+
2031
+ # Preserve lightness
2032
+ self.cb_preserve = QCheckBox("Preserve lightness")
2033
+ self.cb_preserve.setChecked(bool(init.get("preserve_lightness", init.get("preserve", True))))
2034
+
2035
+ # Layout
2036
+ form = QFormLayout(self)
2037
+ form.addRow("Amount (0–1):", self.spin_amount)
2038
+ form.addRow("Neutral mode:", self.combo_mode)
2039
+ form.addRow("", self.cb_preserve)
2040
+
2041
+ btns = QDialogButtonBox(
2042
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
2043
+ parent=self
2044
+ )
2045
+ btns.accepted.connect(self.accept)
2046
+ btns.rejected.connect(self.reject)
2047
+ form.addRow(btns)
2048
+
2049
+ def result_dict(self) -> dict:
2050
+ return {
2051
+ "amount": float(self.spin_amount.value()), # 0..1
2052
+ "mode": self.combo_mode.currentData() or "avg", # "avg" | "max" | "min"
2053
+ "preserve_lightness": bool(self.cb_preserve.isChecked()), # True/False
2054
+ }
2055
+
2056
+
2057
+ class _BackgroundNeutralPresetDialog(QDialog):
2058
+ """
2059
+ Preset UI for Background Neutralization:
2060
+ • Mode: Auto (default) or Rectangle
2061
+ • Rect (normalized): x, y, w, h in [0..1]
2062
+ """
2063
+ def __init__(self, parent=None, initial: dict | None = None):
2064
+ super().__init__(parent)
2065
+ self.setWindowTitle("Background Neutralization — Preset")
2066
+ init = dict(initial or {})
2067
+
2068
+ # Mode radios
2069
+ self.radio_auto = QRadioButton("Auto (50×50 finder)")
2070
+ self.radio_rect = QRadioButton("Rectangle (normalized coords)")
2071
+ mode = (init.get("mode") or "auto").lower()
2072
+ if mode == "rect":
2073
+ self.radio_rect.setChecked(True)
2074
+ else:
2075
+ self.radio_auto.setChecked(True)
2076
+
2077
+ # Rect spinboxes (normalized 0..1)
2078
+ rn = init.get("rect_norm") or [0.40, 0.60, 0.08, 0.06]
2079
+ self.spin_x = QDoubleSpinBox(); self._cfg_norm_box(self.spin_x, rn[0])
2080
+ self.spin_y = QDoubleSpinBox(); self._cfg_norm_box(self.spin_y, rn[1])
2081
+ self.spin_w = QDoubleSpinBox(); self._cfg_norm_box(self.spin_w, rn[2])
2082
+ self.spin_h = QDoubleSpinBox(); self._cfg_norm_box(self.spin_h, rn[3])
2083
+
2084
+ form = QFormLayout(self)
2085
+ form.addRow(self.radio_auto)
2086
+ form.addRow(self.radio_rect)
2087
+ form.addRow("x (0..1):", self.spin_x)
2088
+ form.addRow("y (0..1):", self.spin_y)
2089
+ form.addRow("w (0..1):", self.spin_w)
2090
+ form.addRow("h (0..1):", self.spin_h)
2091
+
2092
+ # Enable/disable rect fields based on mode
2093
+ self.radio_auto.toggled.connect(self._update_enabled)
2094
+ self.radio_rect.toggled.connect(self._update_enabled)
2095
+ self._update_enabled()
2096
+
2097
+ btns = QDialogButtonBox(
2098
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
2099
+ parent=self
2100
+ )
2101
+ btns.accepted.connect(self.accept)
2102
+ btns.rejected.connect(self.reject)
2103
+ form.addRow(btns)
2104
+
2105
+ def _cfg_norm_box(self, box: QDoubleSpinBox, val: float):
2106
+ box.setRange(0.0, 1.0)
2107
+ box.setDecimals(3)
2108
+ box.setSingleStep(0.01)
2109
+ try:
2110
+ box.setValue(float(val))
2111
+ except Exception:
2112
+ box.setValue(0.0)
2113
+
2114
+ def _update_enabled(self):
2115
+ on = self.radio_rect.isChecked()
2116
+ for w in (self.spin_x, self.spin_y, self.spin_w, self.spin_h):
2117
+ w.setEnabled(on)
2118
+
2119
+ def result_dict(self) -> dict:
2120
+ if self.radio_auto.isChecked():
2121
+ return {"mode": "auto"}
2122
+ # sanitize/cap in [0,1]
2123
+ x = max(0.0, min(1.0, float(self.spin_x.value())))
2124
+ y = max(0.0, min(1.0, float(self.spin_y.value())))
2125
+ w = max(0.0, min(1.0, float(self.spin_w.value())))
2126
+ h = max(0.0, min(1.0, float(self.spin_h.value())))
2127
+ # ensure at least a 1e-6 nonzero footprint so integer rounding later doesn't zero-out
2128
+ if w == 0.0: w = 1e-6
2129
+ if h == 0.0: h = 1e-6
2130
+ return {"mode": "rect", "rect_norm": [x, y, w, h]}
2131
+
2132
+ class _WhiteBalancePresetDialog(QDialog):
2133
+ def __init__(self, parent=None, initial: dict | None = None):
2134
+ super().__init__(parent)
2135
+ self.setWindowTitle("White Balance — Preset")
2136
+ init = dict(initial or {})
2137
+
2138
+ v = QVBoxLayout(self)
2139
+
2140
+ # Mode
2141
+ row = QHBoxLayout()
2142
+ row.addWidget(QLabel("Mode:"))
2143
+ self.mode = QComboBox()
2144
+ self.mode.addItems(["Star-Based", "Manual", "Auto"])
2145
+ m = (init.get("mode") or "star").lower()
2146
+ if m == "manual": self.mode.setCurrentText("Manual")
2147
+ elif m == "auto": self.mode.setCurrentText("Auto")
2148
+ else: self.mode.setCurrentText("Star-Based")
2149
+ row.addWidget(self.mode); row.addStretch()
2150
+ v.addLayout(row)
2151
+
2152
+ # Star options
2153
+ self.grp_star = QGroupBox("Star-Based")
2154
+ sv = QGridLayout(self.grp_star)
2155
+ self.spin_thr = QDoubleSpinBox(); self.spin_thr.setRange(0.5, 200.0); self.spin_thr.setDecimals(1)
2156
+ self.spin_thr.setSingleStep(0.5); self.spin_thr.setValue(float(init.get("threshold", 50.0)))
2157
+ self.chk_reuse = QCheckBox("Reuse cached detections"); self.chk_reuse.setChecked(bool(init.get("reuse_cached_sources", True)))
2158
+ sv.addWidget(QLabel("Threshold (σ):"), 0, 0); sv.addWidget(self.spin_thr, 0, 1)
2159
+ sv.addWidget(self.chk_reuse, 1, 0, 1, 2)
2160
+ v.addWidget(self.grp_star)
2161
+
2162
+ # Manual options
2163
+ self.grp_manual = QGroupBox("Manual")
2164
+ gv = QGridLayout(self.grp_manual)
2165
+ self.r = QDoubleSpinBox(); self._cfg_gain(self.r, float(init.get("r_gain", 1.0)))
2166
+ self.g = QDoubleSpinBox(); self._cfg_gain(self.g, float(init.get("g_gain", 1.0)))
2167
+ self.b = QDoubleSpinBox(); self._cfg_gain(self.b, float(init.get("b_gain", 1.0)))
2168
+ gv.addWidget(QLabel("Red gain:"), 0, 0); gv.addWidget(self.r, 0, 1)
2169
+ gv.addWidget(QLabel("Green gain:"), 1, 0); gv.addWidget(self.g, 1, 1)
2170
+ gv.addWidget(QLabel("Blue gain:"), 2, 0); gv.addWidget(self.b, 2, 1)
2171
+ v.addWidget(self.grp_manual)
2172
+
2173
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2174
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2175
+ v.addWidget(btns)
2176
+
2177
+ self.mode.currentTextChanged.connect(self._refresh)
2178
+ self._refresh()
2179
+
2180
+ def _cfg_gain(self, box: QDoubleSpinBox, val: float):
2181
+ box.setRange(0.5, 1.5); box.setDecimals(3); box.setSingleStep(0.01); box.setValue(val)
2182
+
2183
+ def _refresh(self):
2184
+ t = self.mode.currentText()
2185
+ self.grp_star.setVisible(t == "Star-Based")
2186
+ self.grp_manual.setVisible(t == "Manual")
2187
+
2188
+ def result_dict(self) -> dict:
2189
+ t = self.mode.currentText()
2190
+ if t == "Manual":
2191
+ return {"mode": "manual", "r_gain": float(self.r.value()), "g_gain": float(self.g.value()), "b_gain": float(self.b.value())}
2192
+ if t == "Auto":
2193
+ return {"mode": "auto"}
2194
+ return {"mode": "star", "threshold": float(self.spin_thr.value()), "reuse_cached_sources": bool(self.chk_reuse.isChecked())}
2195
+
2196
+
2197
+ class _WaveScaleHDRPresetDialog(QDialog):
2198
+ """
2199
+ Preset UI for WaveScale HDR:
2200
+ • n_scales (2..10)
2201
+ • compression_factor (0.10..5.00)
2202
+ • mask_gamma (0.10..10.00)
2203
+ • decay_rate (0.10..1.00)
2204
+ • optional dim_gamma (enable to store; omit to use auto)
2205
+ """
2206
+ def __init__(self, parent=None, initial: dict | None = None):
2207
+ super().__init__(parent)
2208
+ self.setWindowTitle("WaveScale HDR — Preset")
2209
+ init = dict(initial or {})
2210
+
2211
+ form = QFormLayout(self)
2212
+
2213
+ self.sp_scales = QSpinBox()
2214
+ self.sp_scales.setRange(2, 10)
2215
+ self.sp_scales.setValue(int(init.get("n_scales", 5)))
2216
+
2217
+ self.dp_comp = QDoubleSpinBox()
2218
+ self.dp_comp.setRange(0.10, 5.00)
2219
+ self.dp_comp.setDecimals(2)
2220
+ self.dp_comp.setSingleStep(0.05)
2221
+ self.dp_comp.setValue(float(init.get("compression_factor", 1.50)))
2222
+
2223
+ self.dp_gamma = QDoubleSpinBox()
2224
+ self.dp_gamma.setRange(0.10, 10.00)
2225
+ self.dp_gamma.setDecimals(2)
2226
+ self.dp_gamma.setSingleStep(0.05)
2227
+ # matches slider default of 500 → 5.00
2228
+ self.dp_gamma.setValue(float(init.get("mask_gamma", 5.00)))
2229
+
2230
+ self.dp_decay = QDoubleSpinBox()
2231
+ self.dp_decay.setRange(0.10, 1.00)
2232
+ self.dp_decay.setDecimals(2)
2233
+ self.dp_decay.setSingleStep(0.05)
2234
+ self.dp_decay.setValue(float(init.get("decay_rate", 0.50)))
2235
+
2236
+ # Optional dim gamma
2237
+ row_dim = QHBoxLayout()
2238
+ self.chk_dim = QCheckBox("Use custom dim γ")
2239
+ self.dp_dim = QDoubleSpinBox()
2240
+ self.dp_dim.setRange(0.10, 6.00)
2241
+ self.dp_dim.setDecimals(2)
2242
+ self.dp_dim.setSingleStep(0.05)
2243
+ self.dp_dim.setValue(float(init.get("dim_gamma", 2.00)))
2244
+ if "dim_gamma" in init:
2245
+ self.chk_dim.setChecked(True)
2246
+ self.dp_dim.setEnabled(self.chk_dim.isChecked())
2247
+ self.chk_dim.toggled.connect(self.dp_dim.setEnabled)
2248
+ row_dim.addWidget(self.chk_dim)
2249
+ row_dim.addWidget(self.dp_dim, 1)
2250
+
2251
+ form.addRow("Number of scales:", self.sp_scales)
2252
+ form.addRow("Coarse compression:", self.dp_comp)
2253
+ form.addRow("Mask gamma:", self.dp_gamma)
2254
+ form.addRow("Decay rate:", self.dp_decay)
2255
+ form.addRow("Dimming:", row_dim)
2256
+
2257
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
2258
+ QDialogButtonBox.StandardButton.Cancel, parent=self)
2259
+ btns.accepted.connect(self.accept)
2260
+ btns.rejected.connect(self.reject)
2261
+ form.addRow(btns)
2262
+
2263
+ def result_dict(self) -> dict:
2264
+ out = {
2265
+ "n_scales": int(self.sp_scales.value()),
2266
+ "compression_factor": float(self.dp_comp.value()),
2267
+ "mask_gamma": float(self.dp_gamma.value()),
2268
+ "decay_rate": float(self.dp_decay.value()),
2269
+ }
2270
+ if self.chk_dim.isChecked():
2271
+ out["dim_gamma"] = float(self.dp_dim.value()) # you said you'll add this param
2272
+ return out
2273
+
2274
+ class _WaveScaleDarkEnhancerPresetDialog(QDialog):
2275
+ """
2276
+ Preset UI for WaveScale Dark Enhancer:
2277
+ • n_scales (2–10)
2278
+ • boost_factor (0.10–10.00)
2279
+ • mask_gamma (0.10–10.00)
2280
+ • iterations (1–10)
2281
+ """
2282
+ def __init__(self, parent=None, initial: dict | None = None):
2283
+ super().__init__(parent)
2284
+ self.setWindowTitle("WaveScale Dark Enhancer — Preset")
2285
+ init = dict(initial or {})
2286
+
2287
+ form = QFormLayout(self)
2288
+
2289
+ self.sp_scales = QSpinBox(); self.sp_scales.setRange(2, 10); self.sp_scales.setValue(int(init.get("n_scales", 6)))
2290
+ self.dp_boost = QDoubleSpinBox(); self.dp_boost.setRange(0.10, 10.00); self.dp_boost.setDecimals(2); self.dp_boost.setSingleStep(0.05)
2291
+ self.dp_boost.setValue(float(init.get("boost_factor", 5.00)))
2292
+ self.dp_gamma = QDoubleSpinBox(); self.dp_gamma.setRange(0.10, 10.00); self.dp_gamma.setDecimals(2); self.dp_gamma.setSingleStep(0.05)
2293
+ self.dp_gamma.setValue(float(init.get("mask_gamma", 1.00)))
2294
+ self.sp_iters = QSpinBox(); self.sp_iters.setRange(1, 10); self.sp_iters.setValue(int(init.get("iterations", 2)))
2295
+
2296
+ form.addRow("Number of scales:", self.sp_scales)
2297
+ form.addRow("Boost factor:", self.dp_boost)
2298
+ form.addRow("Mask gamma:", self.dp_gamma)
2299
+ form.addRow("Iterations:", self.sp_iters)
2300
+
2301
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
2302
+ QDialogButtonBox.StandardButton.Cancel,
2303
+ parent=self)
2304
+ btns.accepted.connect(self.accept)
2305
+ btns.rejected.connect(self.reject)
2306
+ form.addRow(btns)
2307
+
2308
+ def result_dict(self) -> dict:
2309
+ return {
2310
+ "n_scales": int(self.sp_scales.value()),
2311
+ "boost_factor": float(self.dp_boost.value()),
2312
+ "mask_gamma": float(self.dp_gamma.value()),
2313
+ "iterations": int(self.sp_iters.value()),
2314
+ }
2315
+
2316
+ class _CLAHEPresetDialog(QDialog):
2317
+ """
2318
+ Preset UI for CLAHE:
2319
+ • clip_limit (0.10–4.00)
2320
+ • tile_px (8–512 px) → converted to OpenCV tileGridSize based on image size
2321
+ """
2322
+ def __init__(self, parent=None, initial: dict | None = None):
2323
+ super().__init__(parent)
2324
+ self.setWindowTitle("CLAHE — Preset")
2325
+ init = dict(initial or {})
2326
+
2327
+ form = QFormLayout(self)
2328
+
2329
+ self.dp_clip = QDoubleSpinBox()
2330
+ self.dp_clip.setRange(0.10, 4.00)
2331
+ self.dp_clip.setDecimals(2)
2332
+ self.dp_clip.setSingleStep(0.10)
2333
+ self.dp_clip.setValue(float(init.get("clip_limit", 2.00)))
2334
+
2335
+ self.sp_tile_px = QSpinBox()
2336
+ self.sp_tile_px.setRange(8, 512)
2337
+ self.sp_tile_px.setSingleStep(8)
2338
+
2339
+ # Support both old and new in the UI:
2340
+ if "tile_px" in init:
2341
+ self.sp_tile_px.setValue(int(init.get("tile_px", 128)))
2342
+ else:
2343
+ # legacy tile count → rough px guess; keeps old presets "reasonable"
2344
+ legacy_tile = int(init.get("tile", 8))
2345
+ legacy_tile = max(2, min(legacy_tile, 128))
2346
+ # Heuristic: convert tile count to a "typical" px size assuming ~2048 min dim
2347
+ px_guess = int(round(2048 / float(legacy_tile)))
2348
+ px_guess = max(8, min(px_guess, 512))
2349
+ # snap to step 8
2350
+ px_guess = int(round(px_guess / 8)) * 8
2351
+ self.sp_tile_px.setValue(px_guess)
2352
+
2353
+ form.addRow("Clip limit:", self.dp_clip)
2354
+ form.addRow("Tile size (px):", self.sp_tile_px)
2355
+
2356
+ btns = QDialogButtonBox(
2357
+ QDialogButtonBox.StandardButton.Ok |
2358
+ QDialogButtonBox.StandardButton.Cancel,
2359
+ parent=self
2360
+ )
2361
+ btns.accepted.connect(self.accept)
2362
+ btns.rejected.connect(self.reject)
2363
+ form.addRow(btns)
2364
+
2365
+ def result_dict(self) -> dict:
2366
+ return {
2367
+ "clip_limit": float(self.dp_clip.value()),
2368
+ "tile_px": int(self.sp_tile_px.value()),
2369
+ }
2370
+
2371
+ class _MorphologyPresetDialog(QDialog):
2372
+ def __init__(self, parent=None, initial: dict | None = None):
2373
+ super().__init__(parent)
2374
+ self.setWindowTitle("Morphology — Preset")
2375
+ init = dict(initial or {})
2376
+
2377
+ form = QFormLayout(self)
2378
+
2379
+ self.op = QComboBox()
2380
+ self.op.addItems(["Erosion", "Dilation", "Opening", "Closing"])
2381
+ op = (init.get("operation","erosion") or "erosion").lower()
2382
+ idx = {"erosion":0,"dilation":1,"opening":2,"closing":3}.get(op,0)
2383
+ self.op.setCurrentIndex(idx)
2384
+
2385
+ self.k = QSpinBox(); self.k.setRange(1,31); self.k.setSingleStep(2)
2386
+ kv = int(init.get("kernel", 3)); self.k.setValue(kv if kv%2==1 else kv+1)
2387
+
2388
+ self.it = QSpinBox(); self.it.setRange(1,10); self.it.setValue(int(init.get("iterations",1)))
2389
+
2390
+ form.addRow("Operation:", self.op)
2391
+ form.addRow("Kernel size (odd):", self.k)
2392
+ form.addRow("Iterations:", self.it)
2393
+
2394
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2395
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2396
+ form.addRow(btns)
2397
+
2398
+ def result_dict(self) -> dict:
2399
+ op = ["erosion","dilation","opening","closing"][self.op.currentIndex()]
2400
+ k = int(self.k.value()); k = k if k%2==1 else k+1
2401
+ it = int(self.it.value())
2402
+ return {"operation": op, "kernel": k, "iterations": it}
2403
+
2404
+ class _PixelMathPresetDialog(QDialog):
2405
+ def __init__(self, parent=None, initial: dict | None = None):
2406
+ super().__init__(parent)
2407
+ self.setWindowTitle("Pixel Math — Preset")
2408
+ init = dict(initial or {})
2409
+ v = QVBoxLayout(self)
2410
+ self.rb_single = QRadioButton("Single"); self.rb_single.setChecked(init.get("mode","single")=="single")
2411
+ self.rb_rgb = QRadioButton("Per-channel"); self.rb_rgb.setChecked(init.get("mode","single")=="rgb")
2412
+ row = QHBoxLayout(); row.addWidget(self.rb_single); row.addWidget(self.rb_rgb); row.addStretch(1)
2413
+ v.addLayout(row)
2414
+ self.ed_single = QPlainTextEdit(); self.ed_single.setPlaceholderText("expr"); self.ed_single.setPlainText(init.get("expr",""))
2415
+ v.addWidget(self.ed_single)
2416
+ self.tabs = QTabWidget();
2417
+ self.ed_r, self.ed_g, self.ed_b = QPlainTextEdit(), QPlainTextEdit(), QPlainTextEdit()
2418
+ for ed, name, key in ((self.ed_r,"Red","expr_r"),(self.ed_g,"Green","expr_g"),(self.ed_b,"Blue","expr_b")):
2419
+ w = QWidget(); lay = QVBoxLayout(w); ed.setPlainText(init.get(key,"")); lay.addWidget(ed); self.tabs.addTab(w, name)
2420
+ v.addWidget(self.tabs)
2421
+ self.rb_single.toggled.connect(lambda on: (self.ed_single.setVisible(on), self.tabs.setVisible(not on)))
2422
+ self.rb_single.toggled.emit(self.rb_single.isChecked())
2423
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2424
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2425
+ v.addWidget(btns)
2426
+ def result_dict(self) -> dict:
2427
+ if self.rb_single.isChecked():
2428
+ return {"mode":"single","expr":self.ed_single.toPlainText().strip()}
2429
+ return {"mode":"rgb","expr_r":self.ed_r.toPlainText().strip(),
2430
+ "expr_g":self.ed_g.toPlainText().strip(),"expr_b":self.ed_b.toPlainText().strip()}
2431
+
2432
+ class _SignatureInsertPresetDialog(QDialog):
2433
+ """
2434
+ Preset editor for Signature / Insert.
2435
+ Keeps the PNG path + placement so users can drag a shortcut and re-apply.
2436
+ """
2437
+ POS_KEYS = [
2438
+ ("Top-Left", "top_left"),
2439
+ ("Top-Center", "top_center"),
2440
+ ("Top-Right", "top_right"),
2441
+ ("Middle-Left", "middle_left"),
2442
+ ("Center", "center"),
2443
+ ("Middle-Right", "middle_right"),
2444
+ ("Bottom-Left", "bottom_left"),
2445
+ ("Bottom-Center", "bottom_center"),
2446
+ ("Bottom-Right", "bottom_right"),
2447
+ ]
2448
+
2449
+ def __init__(self, parent, initial: dict | None = None):
2450
+ super().__init__(parent)
2451
+ self.setWindowTitle("Signature / Insert – Preset")
2452
+ self.setMinimumWidth(520)
2453
+
2454
+ init = dict(initial or {})
2455
+ v = QVBoxLayout(self)
2456
+
2457
+ tip = QLabel("Tip: For transparent signatures, use a PNG and “Load from File”. "
2458
+ "Views are RGB, so alpha is not preserved.")
2459
+ tip.setWordWrap(True)
2460
+ tip.setStyleSheet("color:#e0b000;")
2461
+ v.addWidget(tip)
2462
+
2463
+ grid = QGridLayout()
2464
+
2465
+ # File path
2466
+ grid.addWidget(QLabel("Signature file (PNG/JPG/TIF):"), 0, 0)
2467
+ self.ed_path = QLineEdit(init.get("file_path", ""))
2468
+ b_browse = QPushButton("Browse…")
2469
+ def _pick():
2470
+ fp, _ = QFileDialog.getOpenFileName(self, "Select signature image",
2471
+ "", "Images (*.png *.jpg *.jpeg *.tif *.tiff)")
2472
+ if fp: self.ed_path.setText(fp)
2473
+ b_browse.clicked.connect(_pick)
2474
+ grid.addWidget(self.ed_path, 0, 1)
2475
+ grid.addWidget(b_browse, 0, 2)
2476
+
2477
+ # Position
2478
+ grid.addWidget(QLabel("Position:"), 1, 0)
2479
+ self.cb_pos = QComboBox()
2480
+ for text, key in self.POS_KEYS:
2481
+ self.cb_pos.addItem(text, userData=key)
2482
+ want = init.get("position", "bottom_right")
2483
+ idx = max(0, next((i for i,(_,k) in enumerate(self.POS_KEYS) if k == want), 0))
2484
+ self.cb_pos.setCurrentIndex(idx)
2485
+ grid.addWidget(self.cb_pos, 1, 1)
2486
+
2487
+ # Margins
2488
+ grid.addWidget(QLabel("Margin X (px):"), 2, 0)
2489
+ self.sp_mx = QSpinBox(); self.sp_mx.setRange(0, 5000); self.sp_mx.setValue(int(init.get("margin_x", 20)))
2490
+ grid.addWidget(self.sp_mx, 2, 1)
2491
+
2492
+ grid.addWidget(QLabel("Margin Y (px):"), 3, 0)
2493
+ self.sp_my = QSpinBox(); self.sp_my.setRange(0, 5000); self.sp_my.setValue(int(init.get("margin_y", 20)))
2494
+ grid.addWidget(self.sp_my, 3, 1)
2495
+
2496
+ # Scale / Opacity / Rotation
2497
+ grid.addWidget(QLabel("Scale (%)"), 4, 0)
2498
+ self.sp_scale = QSpinBox(); self.sp_scale.setRange(10, 800); self.sp_scale.setValue(int(init.get("scale", 100)))
2499
+ grid.addWidget(self.sp_scale, 4, 1)
2500
+
2501
+ grid.addWidget(QLabel("Opacity (%)"), 5, 0)
2502
+ self.sp_op = QSpinBox(); self.sp_op.setRange(0, 100); self.sp_op.setValue(int(init.get("opacity", 100)))
2503
+ grid.addWidget(self.sp_op, 5, 1)
2504
+
2505
+ grid.addWidget(QLabel("Rotation (°)"), 6, 0)
2506
+ self.sp_rot = QSpinBox(); self.sp_rot.setRange(-180, 180); self.sp_rot.setValue(int(init.get("rotation", 0)))
2507
+ grid.addWidget(self.sp_rot, 6, 1)
2508
+
2509
+ # Auto affix
2510
+ self.cb_affix = QCheckBox("Auto-affix after placement")
2511
+ self.cb_affix.setChecked(bool(init.get("auto_affix", True)))
2512
+ grid.addWidget(self.cb_affix, 7, 0, 1, 2)
2513
+
2514
+ v.addLayout(grid)
2515
+
2516
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
2517
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2518
+ v.addWidget(btns)
2519
+
2520
+ def result_dict(self) -> dict:
2521
+ return {
2522
+ "file_path": self.ed_path.text().strip(),
2523
+ "position": self.cb_pos.currentData(),
2524
+ "margin_x": int(self.sp_mx.value()),
2525
+ "margin_y": int(self.sp_my.value()),
2526
+ "scale": int(self.sp_scale.value()),
2527
+ "opacity": int(self.sp_op.value()),
2528
+ "rotation": int(self.sp_rot.value()),
2529
+ "auto_affix": bool(self.cb_affix.isChecked()),
2530
+ }
2531
+
2532
+ class _HaloBGonPresetDialog(QDialog):
2533
+ def __init__(self, parent=None, initial: dict | None = None):
2534
+ super().__init__(parent)
2535
+ self.setWindowTitle("Halo-B-Gon Preset")
2536
+ v = QVBoxLayout(self)
2537
+ g = QGridLayout(); v.addLayout(g)
2538
+
2539
+ g.addWidget(QLabel("Reduction:"), 0, 0)
2540
+ self.sl = QSlider(Qt.Orientation.Horizontal); self.sl.setRange(0,3); self.sl.setValue(int((initial or {}).get("reduction",0)))
2541
+ self.lab = QLabel(["Extra Low","Low","Medium","High"][self.sl.value()])
2542
+ self.sl.valueChanged.connect(lambda v: self.lab.setText(["Extra Low","Low","Medium","High"][int(v)]))
2543
+ g.addWidget(self.sl, 0, 1); g.addWidget(self.lab, 0, 2)
2544
+
2545
+ self.cb = QCheckBox("Linear data"); self.cb.setChecked(bool((initial or {}).get("linear",False)))
2546
+ g.addWidget(self.cb, 1, 1)
2547
+
2548
+ row = QHBoxLayout(); v.addLayout(row)
2549
+ ok = QPushButton("OK"); ok.clicked.connect(self.accept)
2550
+ ca = QPushButton("Cancel"); ca.clicked.connect(self.reject)
2551
+ row.addStretch(1); row.addWidget(ok); row.addWidget(ca)
2552
+
2553
+ def result_dict(self) -> dict:
2554
+ return {"reduction": int(self.sl.value()), "linear": bool(self.cb.isChecked())}
2555
+
2556
+ class _RescalePresetDialog(QDialog):
2557
+ """
2558
+ Preset dialog for Geometry → Rescale.
2559
+ Stores: {"factor": float} where factor ∈ [0.10, 10.00].
2560
+ """
2561
+ def __init__(self, parent=None, initial=None):
2562
+ super().__init__(parent)
2563
+ self.setWindowTitle("Rescale Preset")
2564
+ self._initial = initial or {}
2565
+
2566
+ from PyQt6.QtWidgets import QFormLayout, QDoubleSpinBox, QDialogButtonBox
2567
+
2568
+ lay = QVBoxLayout(self)
2569
+ form = QFormLayout()
2570
+
2571
+ self.spn_factor = QDoubleSpinBox(self)
2572
+ self.spn_factor.setDecimals(2)
2573
+ self.spn_factor.setRange(0.10, 10.00)
2574
+ self.spn_factor.setSingleStep(0.05)
2575
+ self.spn_factor.setValue(float(self._initial.get("factor", 1.0)))
2576
+ form.addRow("Scaling factor:", self.spn_factor)
2577
+
2578
+ lay.addLayout(form)
2579
+
2580
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2581
+ btns.accepted.connect(self.accept)
2582
+ btns.rejected.connect(self.reject)
2583
+ lay.addWidget(btns)
2584
+
2585
+ self.resize(320, 120)
2586
+
2587
+ def result_dict(self):
2588
+ return {"factor": float(self.spn_factor.value())}
2589
+
2590
+ class _ImageCombinePresetDialog(QDialog):
2591
+ def __init__(self, parent, initial: dict):
2592
+ super().__init__(parent); self.setWindowTitle("Image Combine Preset")
2593
+ mode = QComboBox(); mode.addItems(["Average","Add","Subtract","Blend","Multiply","Divide","Screen","Overlay","Difference"])
2594
+ mode.setCurrentText(initial.get("mode", "Blend"))
2595
+ alpha = QSlider(Qt.Orientation.Horizontal); alpha.setRange(0,100); alpha.setValue(int(100*float(initial.get("opacity",1.0))))
2596
+ luma = QCheckBox("Luminance only"); luma.setChecked(bool(initial.get("luma_only", False)))
2597
+ out_rep = QRadioButton("Replace A"); out_new = QRadioButton("Create new"); (out_new if initial.get("output")=="new" else out_rep).setChecked(True)
2598
+ from PyQt6.QtWidgets import QLineEdit
2599
+ other = QLineEdit(initial.get("docB_title","")); other.setPlaceholderText("Optional: exact title of B")
2600
+
2601
+ form = QFormLayout()
2602
+ form.addRow("Mode:", mode)
2603
+ form.addRow("Opacity:", alpha)
2604
+ form.addRow("", luma)
2605
+ form.addRow("Output:", None)
2606
+ h = QHBoxLayout(); h.addWidget(out_rep); h.addWidget(out_new); h.addStretch(1)
2607
+ form.addRow("", QLabel(""))
2608
+ root = QVBoxLayout(self); root.addLayout(form); root.addLayout(h)
2609
+ form.addRow("Other source (title):", other)
2610
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2611
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2612
+ root.addWidget(btns)
2613
+ self._mode, self._alpha, self._luma, self._rep, self._other = mode, alpha, luma, out_rep, other
2614
+
2615
+ def result_dict(self):
2616
+ return {
2617
+ "mode": self._mode.currentText(),
2618
+ "opacity": self._alpha.value()/100.0,
2619
+ "luma_only": self._luma.isChecked(),
2620
+ "output": "replace" if self._rep.isChecked() else "new",
2621
+ "docB_title": self._other.text().strip(),
2622
+ }
2623
+
2624
+ class _StarSpikesPresetDialog(QDialog):
2625
+ def __init__(self, parent=None, initial: dict | None = None):
2626
+ super().__init__(parent)
2627
+ self.setWindowTitle("Diffraction Spikes Preset")
2628
+ v = QVBoxLayout(self)
2629
+ g = QGridLayout(); v.addLayout(g)
2630
+ ini = dict(initial or {})
2631
+
2632
+ row = 0
2633
+ def dspin(mini, maxi, step, key, default):
2634
+ sp = QDoubleSpinBox(); sp.setRange(mini, maxi); sp.setSingleStep(step); sp.setValue(float(ini.get(key, default)))
2635
+ return sp
2636
+
2637
+ def ispin(mini, maxi, step, key, default):
2638
+ sp = QSpinBox(); sp.setRange(mini, maxi); sp.setSingleStep(step); sp.setValue(int(ini.get(key, default)))
2639
+ return sp
2640
+
2641
+ self.flux_min = dspin(0.0, 999999.0, 10.0, "flux_min", 30.0); g.addWidget(QLabel("Flux Min:"), row,0); g.addWidget(self.flux_min, row,1); row+=1
2642
+ self.flux_max = dspin(1.0, 999999.0, 50.0, "flux_max", 300.0); g.addWidget(QLabel("Flux Max:"), row,0); g.addWidget(self.flux_max, row,1); row+=1
2643
+ self.bmin = dspin(0.1, 999.0, 0.5, "bscale_min", 10.0); g.addWidget(QLabel("Boost Min:"), row,0); g.addWidget(self.bmin, row,1); row+=1
2644
+ self.bmax = dspin(0.1, 999.0, 0.5, "bscale_max", 30.0); g.addWidget(QLabel("Boost Max:"), row,0); g.addWidget(self.bmax, row,1); row+=1
2645
+ self.smin = dspin(0.1, 999.0, 0.1, "shrink_min", 1.0); g.addWidget(QLabel("Shrink Min:"), row,0); g.addWidget(self.smin, row,1); row+=1
2646
+ self.smax = dspin(0.1, 999.0, 0.1, "shrink_max", 5.0); g.addWidget(QLabel("Shrink Max:"), row,0); g.addWidget(self.smax, row,1); row+=1
2647
+ self.dth = dspin(0.0, 100.0, 0.1, "detect_thresh", 5.0);g.addWidget(QLabel("Detect Threshold:"), row,0); g.addWidget(self.dth, row,1); row+=1
2648
+ self.radius = dspin(1.0, 512.0, 1.0, "radius", 128.0); g.addWidget(QLabel("Pupil Radius:"), row,0); g.addWidget(self.radius, row,1); row+=1
2649
+ self.obstr = dspin(0.0, 0.99, 0.01, "obstruction", 0.2); g.addWidget(QLabel("Obstruction:"), row,0); g.addWidget(self.obstr, row,1); row+=1
2650
+ self.vanes = ispin(2, 8, 1, "num_vanes", 4); g.addWidget(QLabel("Num Vanes:"), row,0); g.addWidget(self.vanes, row,1); row+=1
2651
+ self.vwidth = dspin(0.0, 50.0, 0.5, "vane_width", 4.0); g.addWidget(QLabel("Vane Width:"), row,0); g.addWidget(self.vwidth, row,1); row+=1
2652
+ self.rotdeg = dspin(0.0, 360.0, 1.0, "rotation", 0.0); g.addWidget(QLabel("Rotation (°):"), row,0); g.addWidget(self.rotdeg, row,1); row+=1
2653
+ self.boost = dspin(0.1, 10.0, 0.1, "color_boost", 1.5); g.addWidget(QLabel("Spike Boost:"), row,0); g.addWidget(self.boost, row,1); row+=1
2654
+ self.blur = dspin(0.1, 10.0, 0.1, "blur_sigma", 2.0); g.addWidget(QLabel("PSF Blur Sigma:"), row,0); g.addWidget(self.blur, row,1); row+=1
2655
+
2656
+ self.jwst = QCheckBox("JWST Pupil"); self.jwst.setChecked(bool(ini.get("jwst", False)))
2657
+ g.addWidget(self.jwst, row, 0, 1, 2); row += 1
2658
+
2659
+ rowbox = QHBoxLayout(); v.addLayout(rowbox)
2660
+ ok = QPushButton("OK"); ca = QPushButton("Cancel")
2661
+ ok.clicked.connect(self.accept); ca.clicked.connect(self.reject)
2662
+ rowbox.addStretch(1); rowbox.addWidget(ok); rowbox.addWidget(ca)
2663
+
2664
+ def result_dict(self) -> dict:
2665
+ return {
2666
+ "flux_min": float(self.flux_min.value()),
2667
+ "flux_max": float(self.flux_max.value()),
2668
+ "bscale_min": float(self.bmin.value()),
2669
+ "bscale_max": float(self.bmax.value()),
2670
+ "shrink_min": float(self.smin.value()),
2671
+ "shrink_max": float(self.smax.value()),
2672
+ "detect_thresh": float(self.dth.value()),
2673
+ "radius": float(self.radius.value()),
2674
+ "obstruction": float(self.obstr.value()),
2675
+ "num_vanes": int(self.vanes.value()),
2676
+ "vane_width": float(self.vwidth.value()),
2677
+ "rotation": float(self.rotdeg.value()),
2678
+ "color_boost": float(self.boost.value()),
2679
+ "blur_sigma": float(self.blur.value()),
2680
+ "jwst": bool(self.jwst.isChecked()),
2681
+ }
2682
+
2683
+ class _DebayerPresetDialog(QDialog):
2684
+ def __init__(self, parent=None, initial: dict | None = None):
2685
+ super().__init__(parent)
2686
+ self.setWindowTitle("Debayer — Preset")
2687
+ init = dict(initial or {})
2688
+ self.combo = QComboBox(self)
2689
+ self.combo.addItems(["auto", "RGGB", "BGGR", "GRBG", "GBRG"])
2690
+ want = str(init.get("pattern", "auto")).upper()
2691
+ idx = max(0, self.combo.findText(want, Qt.MatchFlag.MatchFixedString))
2692
+ self.combo.setCurrentIndex(idx)
2693
+
2694
+ lay = QVBoxLayout(self)
2695
+ row = QHBoxLayout(); row.addWidget(QLabel("Bayer pattern:")); row.addWidget(self.combo, 1)
2696
+ lay.addLayout(row)
2697
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2698
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2699
+ lay.addWidget(btns)
2700
+
2701
+ def result_dict(self) -> dict:
2702
+ return {"pattern": self.combo.currentText().upper()}
2703
+
2704
+ from setiastro.saspro.curves_preset import list_custom_presets, _norm_mode
2705
+
2706
+ class _CurvesPresetDialog(QDialog):
2707
+ def __init__(self, parent=None, initial: dict | None = None):
2708
+ super().__init__(parent)
2709
+ self.setWindowTitle("Curves — Preset")
2710
+ init = dict(initial or {})
2711
+
2712
+ # --- Mode ---------------------------------------------------------
2713
+ self.mode = QComboBox()
2714
+ self.mode.addItems(["K (Brightness)", "R", "G", "B", "L*", "a*", "b*", "Chroma", "Saturation"])
2715
+ want = (init.get("mode") or "K (Brightness)").strip()
2716
+ self.mode.setCurrentIndex(max(0, self.mode.findText(want)))
2717
+
2718
+ # --- Shape --------------------------------------------------------
2719
+ self.shape = QComboBox()
2720
+ self.shape.addItem("Linear", "linear")
2721
+ self.shape.addItem("S-curve (mild)", "s_mild")
2722
+ self.shape.addItem("S-curve (medium)", "s_med")
2723
+ self.shape.addItem("S-curve (strong)", "s_strong")
2724
+ self.shape.addItem("Lift shadows", "lift_shadows")
2725
+ self.shape.addItem("Crush shadows", "crush_shadows")
2726
+ self.shape.addItem("Fade blacks", "fade_blacks")
2727
+ self.shape.addItem("Highlight roll-off", "rolloff_highlights")
2728
+ self.shape.addItem("Flatten contrast", "flatten")
2729
+ self.shape.addItem("Custom points", "custom")
2730
+ self.shape.setCurrentIndex(max(0, self.shape.findData((init.get("shape") or "linear").lower())))
2731
+
2732
+ # --- Amount (ignored if custom) -----------------------------------
2733
+ self.amount = QDoubleSpinBox()
2734
+ self.amount.setRange(0.0, 1.0); self.amount.setDecimals(2)
2735
+ self.amount.setSingleStep(0.05)
2736
+ self.amount.setValue(float(init.get("amount", 0.50)))
2737
+
2738
+ # --- Custom points (normalized "x,y; x,y; ...") -------------------
2739
+ self.points = QLineEdit()
2740
+ self.points.setPlaceholderText("points_norm: x,y; x,y; ... (0..1) e.g. 0,0; 0.25,0.15; 0.75,0.85; 1,1")
2741
+ if isinstance(init.get("points_norm"), (list, tuple)) and init["points_norm"]:
2742
+ s = "; ".join(f"{float(x):.6g},{float(y):.6g}" for x, y in init["points_norm"])
2743
+ self.points.setText(s)
2744
+
2745
+ # ===================== Custom Presets picker ======================
2746
+ self.preset_picker = QComboBox()
2747
+ self.btn_load = QPushButton("Load custom → fields")
2748
+
2749
+ # populate & enable/disable based on availability
2750
+ self._rebuild_customs()
2751
+ self.btn_load.clicked.connect(self._load_selected_preset_into_fields)
2752
+
2753
+ # wrap the load-row in a QWidget so we can hide/show the whole row
2754
+ load_row = QHBoxLayout()
2755
+ load_row.setContentsMargins(0, 0, 0, 0)
2756
+ load_row.addWidget(self.btn_load)
2757
+ self._row_custom_controls = QWidget(self)
2758
+ self._row_custom_controls.setLayout(load_row)
2759
+
2760
+ # layout (use explicit labels so they can be hidden with the row)
2761
+ form = QFormLayout(self)
2762
+ form.addRow(QLabel("Mode:", self), self.mode)
2763
+ form.addRow(QLabel("Shape:", self), self.shape)
2764
+ form.addRow(QLabel("Amount (0–1):", self), self.amount)
2765
+ form.addRow(QLabel("Custom points:", self), self.points)
2766
+
2767
+ self._lbl_custom_picker = QLabel("Custom presets:", self)
2768
+ form.addRow(self._lbl_custom_picker, self.preset_picker)
2769
+ form.addRow(QLabel("", self), self._row_custom_controls)
2770
+
2771
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2772
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2773
+ form.addRow(btns)
2774
+
2775
+ # enable/disable + show/hide depending on shape
2776
+ def _update_enabled():
2777
+ custom = (self.shape.currentData() == "custom")
2778
+ self.points.setEnabled(custom)
2779
+ self.amount.setEnabled(custom)
2780
+
2781
+ # show/hide the custom presets UI as requested
2782
+ self._set_custom_picker_visible(custom)
2783
+
2784
+ self.shape.currentIndexChanged.connect(_update_enabled)
2785
+ _update_enabled()
2786
+ # ---------------------------------------------------------------------
2787
+
2788
+ def _set_custom_picker_visible(self, visible: bool):
2789
+ """Show/hide the custom presets picker + load row."""
2790
+ for w in (self._lbl_custom_picker, self.preset_picker, self._row_custom_controls):
2791
+ w.setVisible(bool(visible))
2792
+
2793
+ def _rebuild_customs(self):
2794
+ """Refresh the list from QSettings and (de)activate picker/load."""
2795
+ self.preset_picker.clear()
2796
+ customs = list_custom_presets()
2797
+ if not customs:
2798
+ self.preset_picker.addItem("(No custom presets saved)", userData=None)
2799
+ self.preset_picker.setEnabled(False)
2800
+ self.btn_load.setEnabled(False)
2801
+ return
2802
+ self.preset_picker.setEnabled(True)
2803
+ self.btn_load.setEnabled(True)
2804
+ for p in sorted(customs, key=lambda d: d.get("name", "").lower()):
2805
+ self.preset_picker.addItem(p.get("name", "(unnamed)"), userData=p)
2806
+
2807
+ def _load_selected_preset_into_fields(self):
2808
+ p = self.preset_picker.currentData()
2809
+ if not isinstance(p, dict):
2810
+ return
2811
+ # mode
2812
+ want = _norm_mode(p.get("mode"))
2813
+ idx = self.mode.findText(want)
2814
+ if idx >= 0:
2815
+ self.mode.setCurrentIndex(idx)
2816
+ # switch to custom
2817
+ j = self.shape.findData("custom")
2818
+ if j >= 0:
2819
+ self.shape.setCurrentIndex(j)
2820
+ # points → text
2821
+ pts = p.get("points_norm") or []
2822
+ if isinstance(pts, (list, tuple)) and pts:
2823
+ s = "; ".join(f"{float(x):.6g},{float(y):.6g}" for x, y in pts)
2824
+ self.points.setText(s)
2825
+
2826
+ # -------------------- parsing & result -------------------------------
2827
+ def _parse_points_text(self) -> list[tuple[float, float]]:
2828
+ txt = (self.points.text() or "").strip()
2829
+ if not txt:
2830
+ return []
2831
+ s = txt.replace("\n", ";").replace("\r", ";")
2832
+ parts = [p.strip() for p in s.split(";") if p.strip()]
2833
+ out: list[tuple[float, float]] = []
2834
+ for part in parts:
2835
+ p = part.replace(",", " ").split()
2836
+ if len(p) != 2:
2837
+ continue
2838
+ try:
2839
+ x = float(p[0]); y = float(p[1])
2840
+ except ValueError:
2841
+ continue
2842
+ out.append((max(0.0, min(1.0, x)), max(0.0, min(1.0, y))))
2843
+
2844
+ if out:
2845
+ if all(abs(x - 0.0) > 1e-6 for x, _ in out): out.insert(0, (0.0, 0.0))
2846
+ if all(abs(x - 1.0) > 1e-6 for x, _ in out): out.append((1.0, 1.0))
2847
+ out = sorted(out, key=lambda t: t[0])
2848
+ cleaned, lastx = [], -1.0
2849
+ for x, y in out:
2850
+ if x <= lastx: x = min(1.0, lastx + 1e-4)
2851
+ cleaned.append((x, y)); lastx = x
2852
+ out = cleaned
2853
+ return out
2854
+
2855
+ def result_dict(self) -> dict:
2856
+ mode = _norm_mode(self.mode.currentText())
2857
+ shape = self.shape.currentData() or "linear"
2858
+ amt = float(self.amount.value())
2859
+ d = {"mode": mode, "shape": shape, "amount": amt}
2860
+ if shape == "custom":
2861
+ pts = self._parse_points_text()
2862
+ if pts:
2863
+ d["points_norm"] = pts
2864
+ else:
2865
+ d["shape"] = "linear"
2866
+ d.pop("points_norm", None)
2867
+ return d
2868
+
2869
+ class _GHSPresetDialog(QDialog):
2870
+ def __init__(self, parent=None, initial: dict | None = None):
2871
+ super().__init__(parent)
2872
+ self.setWindowTitle("Universal Hyperbolic Stretch — Preset")
2873
+ init = dict(initial or {})
2874
+
2875
+ self.mode = QComboBox()
2876
+ self.mode.addItems(["K (Brightness)", "R", "G", "B"])
2877
+ want = (init.get("channel") or "K (Brightness)").strip()
2878
+ i = self.mode.findText(want); self.mode.setCurrentIndex(max(0, i))
2879
+
2880
+ def _mk_spin(minv, maxv, step, val, dec=2):
2881
+ s = QDoubleSpinBox(); s.setRange(minv, maxv); s.setDecimals(dec); s.setSingleStep(step); s.setValue(val); return s
2882
+
2883
+ self.alpha = _mk_spin(0.02, 10.0, 0.02, float(init.get("alpha", 1.00)))
2884
+ self.beta = _mk_spin(0.02, 10.0, 0.02, float(init.get("beta", 1.00)))
2885
+ self.gamma = _mk_spin(0.01, 5.0, 0.01, float(init.get("gamma", 1.00)))
2886
+ self.pivot = _mk_spin(0.00, 1.0, 0.01, float(init.get("pivot", 0.50)))
2887
+ self.lp = _mk_spin(0.00, 1.0, 0.01, float(init.get("lp", 0.00)))
2888
+ self.hp = _mk_spin(0.00, 1.0, 0.01, float(init.get("hp", 0.00)))
2889
+
2890
+ form = QFormLayout(self)
2891
+ form.addRow("Channel:", self.mode)
2892
+ form.addRow("α (0.02–10):", self.alpha)
2893
+ form.addRow("β (0.02–10):", self.beta)
2894
+ form.addRow("γ (0.01–5):", self.gamma)
2895
+ form.addRow("Pivot (0–1):", self.pivot)
2896
+ form.addRow("LP (0–1):", self.lp)
2897
+ form.addRow("HP (0–1):", self.hp)
2898
+
2899
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2900
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2901
+ form.addRow(btns)
2902
+
2903
+ def result_dict(self) -> dict:
2904
+ return {
2905
+ "channel": self.mode.currentText(),
2906
+ "alpha": float(self.alpha.value()),
2907
+ "beta": float(self.beta.value()),
2908
+ "gamma": float(self.gamma.value()),
2909
+ "pivot": float(self.pivot.value()),
2910
+ "lp": float(self.lp.value()),
2911
+ "hp": float(self.hp.value()),
2912
+ }
2913
+
2914
+ class _ABEPresetDialog(QDialog):
2915
+ def __init__(self, parent=None, initial: dict | None = None):
2916
+ super().__init__(parent)
2917
+ self.setWindowTitle("ABE — Preset")
2918
+ p = dict(initial or {})
2919
+ form = QFormLayout(self)
2920
+
2921
+ self.degree = QSpinBox(); self.degree.setRange(1, 6); self.degree.setValue(int(p.get("degree", 2)))
2922
+ self.samples = QSpinBox(); self.samples.setRange(20, 100000); self.samples.setSingleStep(20); self.samples.setValue(int(p.get("samples", 120)))
2923
+ self.down = QSpinBox(); self.down.setRange(1, 64); self.down.setValue(int(p.get("downsample", 6)))
2924
+ self.patch = QSpinBox(); self.patch.setRange(5, 151); self.patch.setSingleStep(2); self.patch.setValue(int(p.get("patch", 15)))
2925
+ self.rbf = QCheckBox("Enable RBF"); self.rbf.setChecked(bool(p.get("rbf", True)))
2926
+ self.smooth = QDoubleSpinBox(); self.smooth.setRange(0.0, 10.0); self.smooth.setDecimals(3); self.smooth.setSingleStep(0.01); self.smooth.setValue(float(p.get("rbf_smooth", 1.0)))
2927
+ self.mk_bg = QCheckBox("Also create background document"); self.mk_bg.setChecked(bool(p.get("make_background_doc", False)))
2928
+
2929
+ form.addRow("Polynomial degree:", self.degree)
2930
+ form.addRow("# samples:", self.samples)
2931
+ form.addRow("Downsample:", self.down)
2932
+ form.addRow("Patch size (px):", self.patch)
2933
+ form.addRow(self.rbf)
2934
+ form.addRow("RBF smooth:", self.smooth)
2935
+ form.addRow(self.mk_bg)
2936
+
2937
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
2938
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2939
+ form.addRow(btns)
2940
+
2941
+ def result_dict(self) -> dict:
2942
+ return {
2943
+ "degree": int(self.degree.value()),
2944
+ "samples": int(self.samples.value()),
2945
+ "downsample": int(self.down.value()),
2946
+ "patch": int(self.patch.value()),
2947
+ "rbf": bool(self.rbf.isChecked()),
2948
+ "rbf_smooth": float(self.smooth.value()),
2949
+ "make_background_doc": bool(self.mk_bg.isChecked()),
2950
+ # exclusion polygons: intentionally unsupported here
2951
+ }
2952
+ class _CropPresetDialog(QDialog):
2953
+ def __init__(self, parent=None, initial: dict | None = None):
2954
+ super().__init__(parent)
2955
+ self.setWindowTitle("Crop Preset")
2956
+ init = dict(initial or {})
2957
+ mode = str(init.get("mode", "margins")).lower()
2958
+ margins = dict(init.get("margins", {}))
2959
+
2960
+ lay = QVBoxLayout(self)
2961
+ form = QFormLayout()
2962
+
2963
+ # --- Mode + help button row --------------------------------------
2964
+ self.cmb_mode = QComboBox()
2965
+ self.cmb_mode.addItems(["margins", "rect_norm", "quad_norm"])
2966
+ self.cmb_mode.setCurrentText(mode)
2967
+ # Per-item tooltips
2968
+ self.cmb_mode.setItemData(0, "Crop by pixel margins from each edge.", Qt.ItemDataRole.ToolTipRole)
2969
+ self.cmb_mode.setItemData(1, "Axis-aligned rectangle in 0..1 normalized coords (optional rotation).", Qt.ItemDataRole.ToolTipRole)
2970
+ self.cmb_mode.setItemData(2, "Four corners (TL,TR,BR,BL) in 0..1 normalized coords for perspective/keystone.", Qt.ItemDataRole.ToolTipRole)
2971
+
2972
+ # Tiny "?" button
2973
+ self.btn_mode_help = QToolButton()
2974
+ self.btn_mode_help.setText("?")
2975
+ self.btn_mode_help.setToolTip("What do these modes mean?")
2976
+ self.btn_mode_help.setFixedWidth(24)
2977
+ self.btn_mode_help.clicked.connect(self._show_mode_help)
2978
+
2979
+ # Put combo + help button on one row for the form
2980
+ mode_row = QWidget(self)
2981
+ mode_row_lay = QHBoxLayout(mode_row)
2982
+ mode_row_lay.setContentsMargins(0, 0, 0, 0)
2983
+ mode_row_lay.addWidget(self.cmb_mode, 1)
2984
+ mode_row_lay.addWidget(self.btn_mode_help, 0)
2985
+ form.addRow("Mode:", mode_row)
2986
+ # -----------------------------------------------------------------
2987
+
2988
+ # Margins UI
2989
+ self.top = QSpinBox(); self.right = QSpinBox(); self.bottom = QSpinBox(); self.left = QSpinBox()
2990
+ for sb in (self.top, self.right, self.bottom, self.left):
2991
+ sb.setRange(0, 1_000_000)
2992
+ self.top.setValue(int(margins.get("top", 0)))
2993
+ self.right.setValue(int(margins.get("right", 0)))
2994
+ self.bottom.setValue(int(margins.get("bottom", 0)))
2995
+ self.left.setValue(int(margins.get("left", 0)))
2996
+
2997
+ self.cb_new = QCheckBox("Create new view")
2998
+ self.cb_new.setChecked(bool(init.get("create_new_view", False)))
2999
+ self.le_title = QLineEdit(init.get("title", "Crop"))
3000
+
3001
+ form.addRow("Top (px):", self.top)
3002
+ form.addRow("Right (px):", self.right)
3003
+ form.addRow("Bottom (px):", self.bottom)
3004
+ form.addRow("Left (px):", self.left)
3005
+ form.addRow("", self.cb_new)
3006
+ form.addRow("New view title:", self.le_title)
3007
+ lay.addLayout(form)
3008
+
3009
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
3010
+ btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
3011
+ lay.addWidget(btns)
3012
+
3013
+ def _show_mode_help(self):
3014
+ current = self.cmb_mode.currentText()
3015
+ txt = (
3016
+ "<b>Crop modes</b><br><br>"
3017
+ "<b>margins</b> — Crop by pixel offsets from each image edge.<br>"
3018
+ "• <i>top/right/bottom/left</i> are in pixels.<br><br>"
3019
+ "<b>rect_norm</b> — Axis-aligned rectangle (optionally rotated) expressed in normalized 0..1 units.<br>"
3020
+ "• Schema: { mode:'rect_norm', rect:{ x, y, w, h, angle_deg } }<br>"
3021
+ "• x,y: top-left; w,h: size; angle_deg: CCW rotation around center (optional).<br><br>"
3022
+ "<b>quad_norm</b> — Arbitrary 4-corner crop in normalized 0..1 units (perspective/keystone).<br>"
3023
+ "• Schema: { mode:'quad_norm', quad:[[xTL,yTL],[xTR,yTR],[xBR,yBR],[xBL,yBL]] }<br>"
3024
+ "• Order: TL, TR, BR, BL. (0,0)=top-left, (1,1)=bottom-right."
3025
+ )
3026
+ # Small extra hint for the selected item
3027
+ if current == "rect_norm":
3028
+ txt += "<br><br><i>Tip:</i> Use rect_norm for regular boxes; add a small angle when needed."
3029
+ elif current == "quad_norm":
3030
+ txt += "<br><br><i>Tip:</i> Use quad_norm when the box edges aren’t parallel (keystone or tilt)."
3031
+
3032
+ QMessageBox.information(self, "Crop modes help", txt)
3033
+
3034
+ def result_dict(self) -> dict:
3035
+ return {
3036
+ "mode": self.cmb_mode.currentText(),
3037
+ "margins": {
3038
+ "top": int(self.top.value()),
3039
+ "right": int(self.right.value()),
3040
+ "bottom": int(self.bottom.value()),
3041
+ "left": int(self.left.value()),
3042
+ },
3043
+ "create_new_view": bool(self.cb_new.isChecked()),
3044
+ "title": self.le_title.text().strip() or "Crop",
3045
+ }
3046
+
3047
+ class _RGBAlignPresetDialog(QDialog):
3048
+ def __init__(self, parent=None, initial: dict | None = None):
3049
+ super().__init__(parent)
3050
+ self.setWindowTitle("RGB Align — Preset")
3051
+ init = dict(initial or {})
3052
+ v = QVBoxLayout(self)
3053
+
3054
+ # ── model row ───────────────────────────────────────
3055
+ row = QHBoxLayout()
3056
+ row.addWidget(QLabel("Alignment model:"))
3057
+ self.cb_model = QComboBox()
3058
+ # include EDGE first
3059
+ self.cb_model.addItems(["edge", "homography", "affine", "poly3", "poly4"])
3060
+ want = init.get("model", "edge").lower()
3061
+ idx = max(0, self.cb_model.findText(want, Qt.MatchFlag.MatchFixedString))
3062
+ self.cb_model.setCurrentIndex(idx)
3063
+ row.addWidget(self.cb_model, 1)
3064
+ v.addLayout(row)
3065
+
3066
+ # ── SEP sigma ───────────────────────────────────────
3067
+ sep_row = QHBoxLayout()
3068
+ sep_row.addWidget(QLabel("SEP sigma:"))
3069
+ self.sb_sigma = QSpinBox()
3070
+ self.sb_sigma.setRange(1, 10)
3071
+ self.sb_sigma.setValue(int(init.get("sep_sigma", 3)))
3072
+ self.sb_sigma.setToolTip("Detection threshold (σ) for EDGE mode.\n"
3073
+ "Higher = fewer stars. Only used when model = EDGE.")
3074
+ sep_row.addWidget(self.sb_sigma)
3075
+ v.addLayout(sep_row)
3076
+
3077
+ # ── create new ──────────────────────────────────────
3078
+ self.chk_new = QCheckBox("Create new document")
3079
+ self.chk_new.setChecked(bool(init.get("new_doc", True)))
3080
+ v.addWidget(self.chk_new)
3081
+
3082
+ # ── buttons ─────────────────────────────────────────
3083
+ btns = QDialogButtonBox(
3084
+ QDialogButtonBox.StandardButton.Ok |
3085
+ QDialogButtonBox.StandardButton.Cancel,
3086
+ parent=self
3087
+ )
3088
+ btns.accepted.connect(self.accept)
3089
+ btns.rejected.connect(self.reject)
3090
+ v.addWidget(btns)
3091
+
3092
+ def result_dict(self) -> dict:
3093
+ return {
3094
+ "model": self.cb_model.currentText().lower(), # "edge" / "homography" / ...
3095
+ "sep_sigma": int(self.sb_sigma.value()), # <-- new
3096
+ "new_doc": bool(self.chk_new.isChecked()),
3097
+ }
3098
+
3099
+ class _GeomRotateAnyPresetDialog(QDialog):
3100
+ def __init__(self, parent=None, initial: dict | None = None):
3101
+ super().__init__(parent)
3102
+ self.setWindowTitle("Arbitrary Rotation — Preset")
3103
+ init = dict(initial or {})
3104
+
3105
+ self.spin_angle = QDoubleSpinBox()
3106
+ self.spin_angle.setRange(-360.0, 360.0)
3107
+ self.spin_angle.setDecimals(2)
3108
+ self.spin_angle.setSingleStep(0.25)
3109
+ self.spin_angle.setValue(float(init.get("angle_deg", init.get("angle", 0.0))))
3110
+
3111
+ form = QFormLayout(self)
3112
+ form.addRow("Angle (degrees):", self.spin_angle)
3113
+
3114
+ btns = QDialogButtonBox(
3115
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
3116
+ parent=self
3117
+ )
3118
+ btns.accepted.connect(self.accept)
3119
+ btns.rejected.connect(self.reject)
3120
+ form.addRow(btns)
3121
+
3122
+ def result_dict(self) -> dict:
3123
+ return {
3124
+ "angle_deg": float(self.spin_angle.value()),
3125
+ }