setiastrosuitepro 1.6.2.post1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

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