setiastrosuitepro 1.6.5.post3__py3-none-any.whl

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