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,1596 @@
1
+ # pro/function_bundle.py
2
+ from __future__ import annotations
3
+ import json
4
+ from typing import Iterable, List, Any, Dict
5
+ import sys
6
+ from PyQt6.QtCore import Qt, QSettings, QByteArray, QMimeData, QSize, QPoint, QEventLoop
7
+ from PyQt6.QtWidgets import (
8
+ QDialog, QWidget, QHBoxLayout, QVBoxLayout, QListWidget, QListWidgetItem, QProgressBar,
9
+ QPushButton, QSplitter, QMessageBox, QLabel, QAbstractItemView, QDialogButtonBox,
10
+ QApplication, QMenu, QInputDialog, QPlainTextEdit, QListView
11
+ )
12
+ from PyQt6.QtGui import QDrag, QCloseEvent, QCursor, QShortcut, QKeySequence
13
+ from PyQt6.QtCore import QThread
14
+ import time
15
+ from setiastro.saspro.dnd_mime import MIME_CMD
16
+ from setiastro.saspro.ops.commands import normalize_cid
17
+ def _pin_on_top_mac(win: QDialog):
18
+ if sys.platform == "darwin":
19
+ # Float above normal windows, behave like a palette/tool window
20
+ win.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
21
+ win.setWindowFlag(Qt.WindowType.Tool, True)
22
+ # Keep showing even when app deactivates (mac-only attribute)
23
+ try:
24
+ win.setAttribute(Qt.WidgetAttribute.WA_MacAlwaysShowToolWindow, True)
25
+ except Exception:
26
+ pass
27
+
28
+ # ---------- pack/unpack helpers (lazy to avoid circular imports) ----------
29
+ def _unpack_cmd_safely(raw: bytes):
30
+ try:
31
+ from setiastro.saspro.shortcuts import _unpack_cmd_payload as _unpack
32
+ except Exception:
33
+ _unpack = None
34
+ if _unpack is not None:
35
+ try:
36
+ return _unpack(raw)
37
+ except Exception:
38
+ pass
39
+ try:
40
+ return json.loads(raw.decode("utf-8"))
41
+ except Exception:
42
+ return None
43
+
44
+ def _pack_cmd_safely(payload: dict) -> bytes:
45
+ try:
46
+ from setiastro.saspro.shortcuts import _pack_cmd_payload as _pack
47
+ except Exception:
48
+ _pack = None
49
+ if _pack is not None:
50
+ try:
51
+ data = _pack(payload)
52
+ return bytes(data) if not isinstance(data, (bytes, bytearray)) else data
53
+ except Exception:
54
+ pass
55
+ return json.dumps(payload, ensure_ascii=False).encode("utf-8")
56
+
57
+ # ---------- helpers ----------
58
+ def _find_main_window(w: QWidget):
59
+ p = w.parent()
60
+ while p is not None and not (hasattr(p, "doc_manager") or hasattr(p, "docman")):
61
+ p = p.parent()
62
+ return p
63
+
64
+ def _resolve_doc_and_subwindow(mw, doc_ptr: int):
65
+ if hasattr(mw, "_find_doc_by_id"):
66
+ d, sw = mw._find_doc_by_id(doc_ptr)
67
+ if d is not None:
68
+ return d, sw
69
+ try:
70
+ for sw in mw.mdi.subWindowList():
71
+ vw = sw.widget()
72
+ d = getattr(vw, "document", None)
73
+ if d is not None and id(d) == int(doc_ptr):
74
+ return d, sw
75
+ except Exception:
76
+ pass
77
+ return None, None
78
+
79
+ def _find_shortcut_canvas(mw: QWidget | None) -> QWidget | None:
80
+ if not mw:
81
+ return None
82
+ canv = getattr(getattr(mw, "shortcuts", None), "canvas", None)
83
+ if canv:
84
+ return canv
85
+ try:
86
+ from setiastro.saspro.shortcuts import ShortcutCanvas
87
+ return mw.findChild(ShortcutCanvas)
88
+ except Exception:
89
+ return None
90
+
91
+ # ============================= FunctionBundleChip =============================
92
+ class FunctionBundleChip(QWidget):
93
+ """
94
+ Mini, movable chip for a function-bundle. Parent is the ShortcutCanvas.
95
+ - Left-drag: move inside canvas (smooth, clamped)
96
+ - Ctrl+Drag: start external drag with {"command_id":"function_bundle", "steps":[...]}
97
+ - Drop MIME_CMD: append steps (or expand a dropped function_bundle)
98
+ - Double-click: reopen the dialog (event is accepted)
99
+ """
100
+ def __init__(self, panel: "FunctionBundleDialog", name: str, bundle_key: str, parent_canvas: QWidget):
101
+ super().__init__(parent_canvas)
102
+
103
+ self._panel = panel
104
+ self._bundle_key = bundle_key # <── store bundle key for panel lookups
105
+ self._bundle_index: int | None = None
106
+ self._dragging = False
107
+ self._grab_offset = None
108
+
109
+ self.setAcceptDrops(True)
110
+ self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
111
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
112
+ self.setMouseTracking(True)
113
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # <── allows Delete key
114
+
115
+ self.setObjectName("FunctionBundleChip")
116
+ self.setMinimumSize(240, 44)
117
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
118
+ self.setStyleSheet("""
119
+ QWidget#FunctionBundleChip {
120
+ background: rgba(34, 34, 38, 240);
121
+ color: #ddd;
122
+ border: 1px solid #666;
123
+ border-radius: 8px;
124
+ }
125
+ QLabel#title { font-weight: 600; padding-left: 10px; padding-top: 6px; }
126
+ QLabel#count { color:#aaa; padding-right: 8px; }
127
+ QLabel#hint { color:#bbb; font-size:11px; padding: 0 10px 6px 10px; }
128
+ """)
129
+
130
+ v = QVBoxLayout(self); v.setContentsMargins(6, 4, 6, 4); v.setSpacing(0)
131
+ top = QHBoxLayout(); top.setContentsMargins(0,0,0,0)
132
+ self._title = QLabel(name); self._title.setObjectName("title")
133
+ self._count = QLabel("(0)"); self._count.setObjectName("count")
134
+ top.addWidget(self._title); top.addStretch(1); top.addWidget(self._count)
135
+ v.addLayout(top)
136
+ self._hint = QLabel("Drop shortcuts to add • Alt+Drag to apply")
137
+ self._hint.setObjectName("hint")
138
+ v.addWidget(self._hint)
139
+
140
+ self._sync_count()
141
+
142
+ def _sync_count(self):
143
+ self._count.setText(f"({self._panel.step_count()})")
144
+
145
+ def mousePressEvent(self, ev):
146
+ if ev.button() == Qt.MouseButton.LeftButton:
147
+ self.setFocus(Qt.FocusReason.MouseFocusReason) # <── so Delete works
148
+ self._grab_offset = ev.position() # QPointF in widget coords
149
+ self._dragging = True
150
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
151
+ ev.accept()
152
+ return
153
+ super().mousePressEvent(ev)
154
+
155
+ def mouseMoveEvent(self, ev):
156
+ if not (ev.buttons() & Qt.MouseButton.LeftButton) or not self._dragging:
157
+ super().mouseMoveEvent(ev); return
158
+
159
+ # Alt → start external drag once (matches app gesture)
160
+ if ev.modifiers() & Qt.KeyboardModifier.AltModifier:
161
+ self._dragging = False
162
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
163
+ self._start_external_drag()
164
+ ev.accept(); return
165
+
166
+ parent = self.parentWidget()
167
+ if not parent:
168
+ return
169
+
170
+ global_top_left = ev.globalPosition() - (self._grab_offset or ev.position())
171
+ tl = parent.mapFromGlobal(global_top_left.toPoint())
172
+ max_x = max(0, parent.width() - self.width())
173
+ max_y = max(0, parent.height() - self.height())
174
+ x = min(max(0, tl.x()), max_x)
175
+ y = min(max(0, tl.y()), max_y)
176
+ self.move(x, y)
177
+ ev.accept()
178
+
179
+ def mouseReleaseEvent(self, ev):
180
+ if ev.button() == Qt.MouseButton.LeftButton:
181
+ self._dragging = False
182
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
183
+ # Save layout whenever the user finishes a drag
184
+ try:
185
+ self._panel._save_chip_layout()
186
+ except Exception:
187
+ pass
188
+ ev.accept()
189
+ return
190
+ super().mouseReleaseEvent(ev)
191
+
192
+ def mouseDoubleClickEvent(self, ev):
193
+ try:
194
+ self._panel.showNormal()
195
+ self._panel.raise_()
196
+ self._panel.activateWindow()
197
+ except Exception:
198
+ pass
199
+ ev.accept()
200
+
201
+ def contextMenuEvent(self, ev):
202
+ from PyQt6.QtWidgets import QMenu # already imported at top, but safe
203
+
204
+ m = QMenu(self)
205
+ act_del = m.addAction("Delete Chip")
206
+ act = m.exec(ev.globalPos())
207
+ if act is act_del:
208
+ try:
209
+ self._panel._remove_chip_widget(self)
210
+ except Exception:
211
+ pass
212
+ else:
213
+ ev.ignore()
214
+
215
+ def keyPressEvent(self, ev):
216
+ key = ev.key()
217
+ if key in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
218
+ try:
219
+ self._panel._remove_chip_widget(self)
220
+ except Exception:
221
+ pass
222
+ ev.accept()
223
+ return
224
+ super().keyPressEvent(ev)
225
+
226
+
227
+ def dragEnterEvent(self, e):
228
+ if e.mimeData().hasFormat(MIME_CMD):
229
+ e.acceptProposedAction()
230
+ else:
231
+ e.ignore()
232
+
233
+ def dropEvent(self, e):
234
+ md = e.mimeData()
235
+ if md.hasFormat(MIME_CMD):
236
+ payload = _unpack_cmd_safely(bytes(md.data(MIME_CMD)))
237
+ if not isinstance(payload, dict) or not payload.get("command_id"):
238
+ e.ignore(); return
239
+ if payload.get("command_id") == "function_bundle":
240
+ steps = payload.get("steps") or []
241
+ self._panel._append_steps(steps)
242
+ else:
243
+ self._panel._append_steps([payload])
244
+ self._sync_count()
245
+ e.acceptProposedAction()
246
+ return
247
+ e.ignore()
248
+
249
+ def _start_external_drag(self):
250
+ from PyQt6.QtWidgets import QApplication
251
+
252
+ print(f"[FBChip] _start_external_drag: bundle_key={self._bundle_key}, "
253
+ f"index={self._bundle_index}, name={self._title.text()!r}", flush=True)
254
+ QApplication.processEvents()
255
+
256
+ # Use the bundle that this chip represents, not the panel selection
257
+ if self._bundle_index is not None:
258
+ steps = self._panel.steps_for_index(self._bundle_index)
259
+ else:
260
+ steps = self._panel.current_steps()
261
+
262
+ payload = {
263
+ "command_id": "function_bundle",
264
+ "steps": steps,
265
+ "inherit_target": True,
266
+ }
267
+ print(f"[FBChip] payload steps={len(payload['steps'])}", flush=True)
268
+ QApplication.processEvents()
269
+
270
+ md = QMimeData()
271
+ md.setData(MIME_CMD, QByteArray(_pack_cmd_safely(payload)))
272
+ drag = QDrag(self)
273
+ drag.setMimeData(md)
274
+ drag.setHotSpot(QPoint(self.width() // 2, self.height() // 2))
275
+
276
+ print("[FBChip] starting drag.exec(...)", flush=True)
277
+ QApplication.processEvents()
278
+ drag.exec(Qt.DropAction.CopyAction)
279
+ print("[FBChip] drag.exec finished", flush=True)
280
+ QApplication.processEvents()
281
+
282
+ def set_bundle_index(self, idx: int):
283
+ """Called by the panel so this chip knows which bundle it represents."""
284
+ try:
285
+ self._bundle_index = int(idx)
286
+ except Exception:
287
+ self._bundle_index = None
288
+ self._sync_count()
289
+
290
+ def _sync_count(self):
291
+ # Show the count for *this* bundle, not whatever is currently selected
292
+ if self._bundle_index is not None:
293
+ try:
294
+ n = self._panel.step_count_for_index(self._bundle_index)
295
+ except Exception:
296
+ n = self._panel.step_count()
297
+ else:
298
+ n = self._panel.step_count()
299
+ self._count.setText(f"({n})")
300
+
301
+ # helper to create/place the chip on the ShortcutCanvas
302
+ def _spawn_function_chip_on_canvas(mw: QWidget, panel: "FunctionBundleDialog",
303
+ name: str, bundle_key: str) -> FunctionBundleChip | None:
304
+ canvas = _find_shortcut_canvas(mw)
305
+ if not canvas:
306
+ return None
307
+ chip = FunctionBundleChip(panel, name, bundle_key, parent_canvas=canvas)
308
+ # place near cursor, clamped
309
+ pt = canvas.mapFromGlobal(QCursor.pos()) - chip.rect().center()
310
+ pt.setX(max(0, min(pt.x(), canvas.width() - chip.width())))
311
+ pt.setY(max(0, min(pt.y(), canvas.height() - chip.height())))
312
+ chip.move(pt)
313
+ chip.show()
314
+ chip.raise_()
315
+ return chip
316
+
317
+ def _activate_target_sw(mw, sw):
318
+ try:
319
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
320
+ mw.mdi.setActiveSubWindow(sw)
321
+ w = getattr(sw, "widget", lambda: None)()
322
+ if w:
323
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
324
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
325
+ except Exception:
326
+ pass
327
+
328
+ # ============================= FunctionBundleDialog =============================
329
+ class FunctionBundleDialog(QDialog):
330
+ SETTINGS_KEY = "functionbundles/v1"
331
+ CHIP_KEY = "functionbundles/chips_v1" # <── new
332
+
333
+ def __init__(self, parent: QWidget | None = None):
334
+ super().__init__(parent)
335
+ _pin_on_top_mac(self)
336
+ self.setWindowTitle("Function Bundles")
337
+ self.setWindowFlag(Qt.WindowType.Window, True)
338
+ self.setWindowModality(Qt.WindowModality.NonModal)
339
+ self.setModal(False)
340
+ self.resize(920, 560)
341
+ self.setAcceptDrops(True)
342
+
343
+ self._settings = QSettings()
344
+ self._bundles: List[dict] = self._load_all()
345
+ if not self._bundles:
346
+ self._bundles = [{"name": "Function Bundle 1", "steps": []}]
347
+
348
+ # left: bundles
349
+ self.list = QListWidget()
350
+ self.list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
351
+ self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
352
+
353
+ self.btn_new = QPushButton("New")
354
+ self.btn_dup = QPushButton("Duplicate")
355
+ self.btn_del = QPushButton("Delete")
356
+
357
+ # right: steps
358
+ self.steps = QListWidget()
359
+ self.steps.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
360
+ self.steps.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
361
+ self.steps.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
362
+
363
+ # ✅ make long step text readable
364
+ self.steps.setWordWrap(True) # wrap long lines
365
+ self.steps.setTextElideMode(Qt.TextElideMode.ElideRight) # if still too long, show …
366
+ self.steps.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
367
+ self.steps.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
368
+ self.steps.setResizeMode(QListView.ResizeMode.Adjust) # recompute item layout on width change
369
+ self.steps.setUniformItemSizes(False)
370
+
371
+ self.add_hint = QLabel("Drop shortcuts here to add steps")
372
+ self.add_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
373
+ self.add_hint.setStyleSheet("color:#aaa; padding:6px; border:1px dashed #666; border-radius:6px;")
374
+
375
+ self.btn_edit_preset = QPushButton("Edit Preset…")
376
+ self.btn_edit_preset.setEnabled(False) # enabled when exactly one step is selected
377
+
378
+ self.btn_remove = QPushButton("Remove Selected")
379
+ self.btn_clear = QPushButton("Clear Steps")
380
+ self.btn_up = QPushButton("▲ Move Up")
381
+ self.btn_down = QPushButton("▼ Move Down")
382
+
383
+ self.btn_drag_bundle = QPushButton("Drag Bundle")
384
+ self.btn_run_active = QPushButton("Apply to Active View")
385
+ self.btn_apply_to_vbundle = QPushButton("Apply to View Bundle…")
386
+ self.btn_chip = QPushButton("Compress to Chip")
387
+
388
+ # layout
389
+ left = QVBoxLayout()
390
+ left.addWidget(QLabel("Function Bundles"))
391
+ left.addWidget(self.list, 1)
392
+ row = QHBoxLayout()
393
+ row.addWidget(self.btn_new); row.addWidget(self.btn_dup); row.addWidget(self.btn_del)
394
+ left.addLayout(row)
395
+
396
+ right = QVBoxLayout()
397
+ right.addWidget(QLabel("Steps"))
398
+ right.addWidget(self.steps, 1)
399
+ right.addWidget(self.add_hint)
400
+
401
+ # controls row under the Steps list
402
+ rrow = QHBoxLayout()
403
+ rrow.addWidget(self.btn_up)
404
+ rrow.addWidget(self.btn_down)
405
+
406
+ # center Edit Preset between Move Down and Remove Selected
407
+ rrow.addStretch(1)
408
+ rrow.addWidget(self.btn_edit_preset)
409
+ rrow.addStretch(1)
410
+
411
+ # then Remove/Clear on the right
412
+ rrow.addWidget(self.btn_remove)
413
+ rrow.addWidget(self.btn_clear)
414
+
415
+ right.addLayout(rrow)
416
+
417
+ self.run_status = QLabel("Ready.")
418
+ self.run_status.setStyleSheet("color:#aaa; padding:2px 0;")
419
+ self.run_status.setWordWrap(True) # Fix for window stretching on long text
420
+ self.run_progress = QProgressBar()
421
+ self.run_progress.setMinimum(0)
422
+ self.run_progress.setMaximum(100)
423
+ self.run_progress.setValue(0)
424
+ self.run_progress.setTextVisible(True)
425
+
426
+ prow = QHBoxLayout()
427
+ prow.addWidget(self.run_status, 1)
428
+ prow.addWidget(self.run_progress, 2)
429
+ right.addLayout(prow)
430
+
431
+ # right.addWidget(self.btn_drag_bundle)
432
+ right.addWidget(self.btn_run_active)
433
+ right.addWidget(self.btn_apply_to_vbundle)
434
+ right.addWidget(self.btn_chip)
435
+
436
+ split = QSplitter()
437
+ wl = QWidget(); wl.setLayout(left)
438
+ wr = QWidget(); wr.setLayout(right)
439
+ split.addWidget(wl); split.addWidget(wr)
440
+ split.setStretchFactor(0, 0)
441
+ split.setStretchFactor(1, 1)
442
+
443
+ root = QHBoxLayout(self)
444
+ root.addWidget(split)
445
+
446
+
447
+ # wire
448
+ self.list.currentRowChanged.connect(lambda _i: self._refresh_steps_list())
449
+ self.list.customContextMenuRequested.connect(self._bundles_context_menu)
450
+ self.btn_new.clicked.connect(self._new_bundle)
451
+ self.btn_dup.clicked.connect(self._dup_bundle)
452
+ self.btn_del.clicked.connect(self._del_bundle)
453
+ # rename shortcuts
454
+ QShortcut(QKeySequence("F2"), self.list, activated=self._rename_bundle)
455
+ self.list.itemDoubleClicked.connect(lambda _it: self._rename_bundle())
456
+
457
+ # step context menu
458
+ self.steps.customContextMenuRequested.connect(self._steps_context_menu)
459
+
460
+ self.steps.itemSelectionChanged.connect(self._sync_edit_button_enabled)
461
+ self.btn_edit_preset.clicked.connect(self._edit_selected_step_preset)
462
+ QShortcut(QKeySequence("Return"), self.steps, activated=self._edit_selected_step_preset) # handy
463
+ QShortcut(QKeySequence("Enter"), self.steps, activated=self._edit_selected_step_preset)
464
+
465
+ self.btn_remove.clicked.connect(self._remove_selected_steps)
466
+ self.btn_clear.clicked.connect(self._clear_steps)
467
+ self.btn_up.clicked.connect(lambda: self._move_steps(-1))
468
+ self.btn_down.clicked.connect(lambda: self._move_steps(+1))
469
+
470
+ self.btn_drag_bundle.clicked.connect(self._drag_bundle)
471
+ self.btn_run_active.clicked.connect(self._apply_to_active_view)
472
+ self.btn_apply_to_vbundle.clicked.connect(self._apply_to_view_bundle)
473
+ self.btn_chip.clicked.connect(self._compress_to_chip)
474
+
475
+ # populate
476
+ self._refresh_bundle_list()
477
+ if self.list.count():
478
+ self.list.setCurrentRow(0)
479
+
480
+ QShortcut(QKeySequence("Delete"), self.steps, activated=self._remove_selected_steps)
481
+ QShortcut(QKeySequence("Backspace"), self.steps, activated=self._remove_selected_steps)
482
+ QShortcut(QKeySequence("Ctrl+A"), self.steps, activated=self.steps.selectAll)
483
+
484
+ # chips per bundle index
485
+ self._chips: dict[int, FunctionBundleChip] = {}
486
+
487
+ # Restore any chips that were saved in QSettings
488
+ try:
489
+ self._restore_chips_from_settings()
490
+ except Exception:
491
+ pass
492
+
493
+ def _save_chip_layout(self):
494
+ """
495
+ Persist current chips and their positions to QSettings so they
496
+ reappear on the canvas next time SASpro is opened.
497
+ """
498
+ try:
499
+ data = []
500
+ for idx, chip in list(self._chips.items()):
501
+ if chip is None or chip.parent() is None:
502
+ continue
503
+ pos = chip.pos()
504
+ data.append({
505
+ "index": int(idx),
506
+ "x": int(pos.x()),
507
+ "y": int(pos.y()),
508
+ })
509
+ self._settings.setValue(self.CHIP_KEY, json.dumps(data, ensure_ascii=False))
510
+ self._settings.sync()
511
+ except Exception:
512
+ pass
513
+
514
+ def _restore_chips_from_settings(self):
515
+ """
516
+ Recreate chips on the ShortcutCanvas from saved layout.
517
+ Called on dialog init.
518
+ """
519
+ mw = _find_main_window(self)
520
+ if not mw:
521
+ return
522
+
523
+ raw = self._settings.value(self.CHIP_KEY, "[]", type=str)
524
+ try:
525
+ data = json.loads(raw)
526
+ except Exception:
527
+ data = []
528
+
529
+ if not isinstance(data, list):
530
+ return
531
+
532
+ for entry in data:
533
+ try:
534
+ idx = int(entry.get("index", -1))
535
+ except Exception:
536
+ continue
537
+ if idx < 0 or idx >= len(self._bundles):
538
+ continue
539
+
540
+ name = self._bundles[idx].get("name", "Function Bundle")
541
+ chip = _spawn_function_chip_on_canvas(mw, self, name, bundle_key=f"fn-{idx}")
542
+ if chip is None:
543
+ continue
544
+
545
+ # Restore position if provided
546
+ x = entry.get("x")
547
+ y = entry.get("y")
548
+ if isinstance(x, int) and isinstance(y, int):
549
+ chip.move(x, y)
550
+
551
+ self._chips[idx] = chip
552
+ try:
553
+ chip.set_bundle_index(idx)
554
+ except Exception:
555
+ pass
556
+
557
+ def reload_from_settings_after_import(self):
558
+ """
559
+ Reload bundles + chips from QSettings after an external import
560
+ (e.g., shortcuts .sass import).
561
+ """
562
+ try:
563
+ self._bundles = self._load_all()
564
+ except Exception:
565
+ self._bundles = []
566
+ self._refresh_bundle_list()
567
+ if self.list.count():
568
+ self.list.setCurrentRow(0)
569
+
570
+ # Remove existing chips from canvas
571
+ for ch in list(self._chips.values()):
572
+ try:
573
+ ch.setParent(None)
574
+ ch.deleteLater()
575
+ except Exception:
576
+ pass
577
+ self._chips.clear()
578
+
579
+ # And recreate them from CHIP_KEY
580
+ try:
581
+ self._restore_chips_from_settings()
582
+ except Exception:
583
+ pass
584
+
585
+
586
+ def _remove_chip_widget(self, chip: FunctionBundleChip):
587
+ """
588
+ Remove a chip from the canvas and from our registry, without
589
+ deleting the underlying function bundle.
590
+ """
591
+ # Drop from the index → chip dict
592
+ for idx, ch in list(self._chips.items()):
593
+ if ch is chip:
594
+ self._chips.pop(idx, None)
595
+ break
596
+
597
+ try:
598
+ chip.setParent(None)
599
+ chip.deleteLater()
600
+ except Exception:
601
+ pass
602
+
603
+ self._save_chip_layout()
604
+
605
+
606
+ def _progress_reset(self):
607
+ try:
608
+ self.run_status.setText("Ready.")
609
+ self.run_progress.setRange(0, 100)
610
+ self.run_progress.setValue(0)
611
+ QApplication.processEvents()
612
+ except Exception:
613
+ pass
614
+
615
+ def _progress_set_step(self, idx: int, total: int, label: str):
616
+ """Determinate update for normal steps."""
617
+ try:
618
+ idx = max(0, idx)
619
+ total = max(1, total)
620
+ pct = int(100 * idx / total)
621
+ self.run_status.setText(f"Running step {idx}/{total}: {label}")
622
+ self.run_progress.setRange(0, 100)
623
+ self.run_progress.setValue(pct)
624
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
625
+ except Exception:
626
+ pass
627
+
628
+ def _progress_busy(self, label: str):
629
+ """Indeterminate mode for long-running sub-steps (e.g., Cosmic Clarity)."""
630
+ try:
631
+ self.run_status.setText(label)
632
+ self.run_progress.setRange(0, 0) # indeterminate
633
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
634
+ except Exception:
635
+ pass
636
+
637
+ def _step_label(self, st: dict) -> str:
638
+ cid = (st or {}).get("command_id", "<cmd>")
639
+ # If preset has a friendly name/label, include it
640
+ preset = (st or {}).get("preset")
641
+ if isinstance(preset, dict):
642
+ name = preset.get("name") or preset.get("label")
643
+ if isinstance(name, str) and name.strip():
644
+ return f"{cid} — {name.strip()}"
645
+ return str(cid)
646
+
647
+
648
+ def _sync_edit_button_enabled(self):
649
+ self.btn_edit_preset.setEnabled(len(self.steps.selectedItems()) == 1)
650
+
651
+ def _edit_selected_step_preset(self):
652
+ items = self.steps.selectedItems()
653
+ if len(items) != 1:
654
+ return
655
+ it = items[0]
656
+ step = it.data(Qt.ItemDataRole.UserRole) or {}
657
+ new_preset, ok = self._edit_preset_dialog(step.get("preset", None), step)
658
+ if ok:
659
+ step["preset"] = new_preset
660
+ it.setData(Qt.ItemDataRole.UserRole, step)
661
+ it.setText(f"{step.get('command_id','<cmd>')}{self._preset_label(new_preset)}")
662
+ self._commit_steps_from_ui()
663
+
664
+
665
+ # ---------- small UI pump ----------
666
+ def _pump_events(self, ms: int = 0):
667
+ """Keep UI responsive between long steps."""
668
+ try:
669
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, ms)
670
+ except Exception:
671
+ pass
672
+
673
+ # ---------- CC wait helpers ----------
674
+ def _is_cc_running(self, mw) -> bool:
675
+ # main-window flag
676
+ try:
677
+ if getattr(mw, "_cosmicclarity_headless_running", False):
678
+ return True
679
+ except Exception:
680
+ pass
681
+ # QSettings flag
682
+ try:
683
+ v = QSettings().value("cc/headless_in_progress", False, type=bool)
684
+ except Exception:
685
+ v = bool(QSettings().value("cc/headless_in_progress", False))
686
+ return bool(v)
687
+
688
+ def _wait_for_cosmicclarity(self, mw, timeout_ms: int = 2 * 60 * 60 * 1000, poll_ms: int = 50):
689
+ """If CC is running, wait here (processing events) until it finishes."""
690
+ if not self._is_cc_running(mw):
691
+ return
692
+ try:
693
+ QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor)
694
+ except Exception:
695
+ pass
696
+ t0 = time.monotonic()
697
+ while self._is_cc_running(mw) and (time.monotonic() - t0) * 1000 < timeout_ms:
698
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100)
699
+ QThread.msleep(poll_ms)
700
+ try:
701
+ QApplication.restoreOverrideCursor()
702
+ except Exception:
703
+ pass
704
+
705
+ # ---------- persistence ----------
706
+ def _load_all(self) -> List[dict]:
707
+ raw = self._settings.value(self.SETTINGS_KEY, "[]", type=str)
708
+ try:
709
+ data = json.loads(raw)
710
+ if isinstance(data, list):
711
+ out = []
712
+ for b in data:
713
+ if not isinstance(b, dict): continue
714
+ nm = (b.get("name") or "Function Bundle").strip()
715
+ steps = b.get("steps") or []
716
+ if isinstance(steps, list):
717
+ out.append({"name": nm, "steps": steps})
718
+ return out
719
+ except Exception:
720
+ pass
721
+ return []
722
+
723
+ def _save_all(self):
724
+ try:
725
+ self._settings.setValue(self.SETTINGS_KEY, json.dumps(self._bundles, ensure_ascii=False))
726
+ self._settings.sync() # <- add this line
727
+ except Exception:
728
+ pass
729
+
730
+ # ---------- bundle helpers ----------
731
+ def _current_index(self) -> int:
732
+ i = self.list.currentRow()
733
+ return -1 if i < 0 or i >= len(self._bundles) else i
734
+
735
+ def _current_bundle(self) -> dict | None:
736
+ i = self._current_index()
737
+ return None if i < 0 else self._bundles[i]
738
+
739
+ def current_steps(self) -> list:
740
+ b = self._current_bundle()
741
+ return [] if not b else list(b.get("steps", []))
742
+
743
+ def step_count(self) -> int:
744
+ return len(self.current_steps())
745
+
746
+ # ---------- list refresh ----------
747
+ def _refresh_bundle_list(self):
748
+ self.list.clear()
749
+ for b in self._bundles:
750
+ self.list.addItem(QListWidgetItem(b.get("name", "Function Bundle")))
751
+
752
+ def _refresh_steps_list(self):
753
+ self.steps.clear()
754
+ for st in self.current_steps():
755
+ self._add_step_item(st)
756
+
757
+ def _preset_label(self, preset) -> str:
758
+ """Human-friendly label for the preset shown in the list."""
759
+ if preset is None:
760
+ return ""
761
+ if isinstance(preset, str):
762
+ return f" — {preset}"
763
+ if isinstance(preset, dict):
764
+ # Prefer a human name if present
765
+ name = preset.get("name") or preset.get("label")
766
+ if isinstance(name, str) and name.strip():
767
+ return f" — {name.strip()}"
768
+ # Otherwise a tiny summary like {k1,k2}
769
+ keys = list(preset.keys())
770
+ return f" — {{{', '.join(keys[:3])}{'…' if len(keys)>3 else ''}}}"
771
+ # fallback
772
+ return f" — {str(preset)}"
773
+
774
+ def _add_step_item(self, step: dict, at: int | None = None):
775
+ cid = step.get("command_id", "<cmd>")
776
+ preset = step.get("preset", None)
777
+ desc = f"{cid}{self._preset_label(preset)}"
778
+
779
+ it = QListWidgetItem(desc)
780
+ it.setToolTip(desc) # ✅ hover shows full line
781
+ it.setData(Qt.ItemDataRole.UserRole, step)
782
+
783
+ if at is None:
784
+ self.steps.addItem(it)
785
+ else:
786
+ self.steps.insertItem(at, it)
787
+
788
+
789
+ def _collect_steps_from_ui(self) -> list:
790
+ out = []
791
+ for i in range(self.steps.count()):
792
+ s = self.steps.item(i).data(Qt.ItemDataRole.UserRole)
793
+ if isinstance(s, dict): out.append(s)
794
+ return out
795
+
796
+ def _commit_steps_from_ui(self):
797
+ i = self._current_index()
798
+ if i < 0: return
799
+ self._bundles[i]["steps"] = self._collect_steps_from_ui()
800
+ self._save_all()
801
+ if i in self._chips:
802
+ self._chips[i]._sync_count()
803
+ # refresh visible labels (e.g. after preset edits)
804
+ self._refresh_steps_list()
805
+
806
+ # ---------- editing actions ----------
807
+ def _new_bundle(self):
808
+ self._bundles.append({"name": f"Function Bundle {len(self._bundles)+1}", "steps": []})
809
+ self._save_all(); self._refresh_bundle_list()
810
+ self.list.setCurrentRow(self.list.count() - 1)
811
+
812
+ def _dup_bundle(self):
813
+ i = self._current_index()
814
+ if i < 0: return
815
+ b = self._bundles[i]
816
+ cp = {"name": f"{b.get('name','Function Bundle')} (copy)", "steps": list(b.get("steps", []))}
817
+ self._bundles.insert(i + 1, cp)
818
+ self._save_all(); self._refresh_bundle_list()
819
+ self.list.setCurrentRow(i + 1)
820
+
821
+ def _del_bundle(self):
822
+ i = self._current_index()
823
+ if i < 0: return
824
+ # close any chip for that index
825
+ ch = self._chips.pop(i, None)
826
+ if ch:
827
+ try:
828
+ ch.setParent(None)
829
+ ch.deleteLater()
830
+ except Exception:
831
+ pass
832
+
833
+ del self._bundles[i]
834
+ self._save_all()
835
+ self._refresh_bundle_list()
836
+ if self.list.count():
837
+ self.list.setCurrentRow(min(i, self.list.count() - 1))
838
+
839
+ # Also update chip layout persistence
840
+ try:
841
+ self._save_chip_layout()
842
+ except Exception:
843
+ pass
844
+
845
+ def _remove_selected_steps(self):
846
+ rows = sorted({ix.row() for ix in self.steps.selectedIndexes()}, reverse=True)
847
+ for r in rows:
848
+ self.steps.takeItem(r)
849
+ self._commit_steps_from_ui()
850
+
851
+ def _clear_steps(self):
852
+ self.steps.clear()
853
+ self._commit_steps_from_ui()
854
+
855
+ def _move_steps(self, delta: int):
856
+ if not self.steps.selectedItems():
857
+ return
858
+ items = self.steps.selectedItems()
859
+ rows = sorted([self.steps.row(it) for it in items])
860
+ for idx in (rows if delta < 0 else reversed(rows)):
861
+ it = self.steps.takeItem(idx)
862
+ new_idx = max(0, min(self.steps.count(), idx + delta))
863
+ self.steps.insertItem(new_idx, it)
864
+ it.setSelected(True)
865
+ self._commit_steps_from_ui()
866
+
867
+ def _append_steps(self, steps: Iterable[dict]):
868
+ for st in steps:
869
+ if isinstance(st, dict) and st.get("command_id"):
870
+ self._add_step_item(st)
871
+ self._commit_steps_from_ui()
872
+
873
+ # ---------- rename bundle ----------
874
+ def _rename_bundle(self):
875
+ i = self._current_index()
876
+ if i < 0:
877
+ return
878
+ cur = self._bundles[i]
879
+ new_name, ok = QInputDialog.getText(self, "Rename Function Bundle",
880
+ "New name:", text=cur.get("name","Function Bundle"))
881
+ if not ok:
882
+ return
883
+ cur["name"] = (new_name or "Function Bundle").strip()
884
+ self._save_all()
885
+ self._refresh_bundle_list()
886
+ self.list.setCurrentRow(i)
887
+ # update chip title if present
888
+ ch = self._chips.get(i)
889
+ if ch:
890
+ ch._title.setText(cur["name"])
891
+
892
+ def _bundles_context_menu(self, pos):
893
+ if self.list.count() == 0:
894
+ return
895
+ m = QMenu(self)
896
+ act_ren = m.addAction("Rename…")
897
+ act = m.exec(self.list.mapToGlobal(pos))
898
+ if act is act_ren:
899
+ self._rename_bundle()
900
+
901
+ # ---------- step context menu & preset editor ----------
902
+ def _steps_context_menu(self, pos):
903
+ item = self.steps.itemAt(pos)
904
+ if not item:
905
+ return
906
+ m = QMenu(self)
907
+ a_edit = m.addAction("Edit Preset…")
908
+ a_clear = m.addAction("Clear Preset")
909
+ m.addSeparator()
910
+ a_dup = m.addAction("Duplicate Step")
911
+ a_rem = m.addAction("Remove Step")
912
+ act = m.exec(self.steps.mapToGlobal(pos))
913
+ if not act:
914
+ return
915
+ row = self.steps.row(item)
916
+ step = item.data(Qt.ItemDataRole.UserRole) or {}
917
+ if act is a_edit:
918
+ new_preset, ok = self._edit_preset_dialog(step.get("preset", None), step)
919
+ if ok:
920
+ step["preset"] = new_preset
921
+ item.setData(Qt.ItemDataRole.UserRole, step)
922
+ item.setText(f"{step.get('command_id','<cmd>')}{self._preset_label(new_preset)}")
923
+ self._commit_steps_from_ui()
924
+ elif act is a_clear:
925
+ if "preset" in step:
926
+ step.pop("preset", None)
927
+ item.setData(Qt.ItemDataRole.UserRole, step)
928
+ item.setText(f"{step.get('command_id','<cmd>')}")
929
+ self._commit_steps_from_ui()
930
+ elif act is a_dup:
931
+ self._add_step_item(json.loads(json.dumps(step)), at=row+1)
932
+ self._commit_steps_from_ui()
933
+ elif act is a_rem:
934
+ self.steps.takeItem(row)
935
+ self._commit_steps_from_ui()
936
+
937
+ def _edit_preset_dialog(self, current, step: dict | None = None) -> tuple[object, bool]:
938
+ """
939
+ Prefer the same rich UI editors used by desktop shortcuts.
940
+ - If a bespoke editor exists and user cancels => do NOT open JSON.
941
+ - If no bespoke editor exists => fall back to JSON editor.
942
+ Returns (value, ok).
943
+ """
944
+ # Try to open a command-specific UI if we know the command_id for the selected step
945
+ cmd = None
946
+ if isinstance(step, dict):
947
+ cmd = step.get("command_id")
948
+
949
+ try:
950
+ from setiastro.saspro.shortcuts import _open_preset_editor_for_command, _has_preset_editor_for_command
951
+ except Exception:
952
+ _open_preset_editor_for_command = None
953
+ _has_preset_editor_for_command = lambda _c: False # type: ignore
954
+
955
+ # If we have a bespoke UI for this command, use it; cancel means "do nothing"
956
+ if cmd and _has_preset_editor_for_command(cmd) and _open_preset_editor_for_command:
957
+ result = _open_preset_editor_for_command(self, cmd, current if isinstance(current, dict) else {})
958
+ if result is None:
959
+ return current, False # user cancelled rich UI → don't open JSON
960
+ return result, True # accepted via rich UI
961
+
962
+ # ---- Fallback: JSON editor (only when no bespoke editor exists) ----
963
+ dlg = QDialog(self)
964
+ dlg.setWindowTitle("Edit Preset")
965
+ v = QVBoxLayout(dlg)
966
+ v.addWidget(QLabel("Edit the preset as JSON (e.g. {\"name\":\"My Preset\", \"strength\": 0.8})"))
967
+ edit = QPlainTextEdit()
968
+ edit.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
969
+ try:
970
+ seed = json.dumps(current, ensure_ascii=False, indent=2)
971
+ except Exception:
972
+ seed = json.dumps(current if current is not None else {}, ensure_ascii=False, indent=2)
973
+ edit.setPlainText(seed)
974
+ v.addWidget(edit, 1)
975
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
976
+ v.addWidget(buttons)
977
+ buttons.accepted.connect(dlg.accept); buttons.rejected.connect(dlg.reject)
978
+ if dlg.exec() != QDialog.DialogCode.Accepted:
979
+ return current, False
980
+ txt = edit.toPlainText().strip()
981
+ if not txt:
982
+ return None, True
983
+ try:
984
+ val = json.loads(txt)
985
+ except Exception as e:
986
+ QMessageBox.warning(self, "Invalid JSON", f"Could not parse JSON:\n{e}")
987
+ return current, False
988
+ return val, True
989
+
990
+
991
+
992
+ # ---------- DnD into the PANEL (add steps) ----------
993
+ def dragEnterEvent(self, e):
994
+ if e.mimeData().hasFormat(MIME_CMD):
995
+ e.acceptProposedAction()
996
+ else:
997
+ e.ignore()
998
+
999
+ def dropEvent(self, e):
1000
+ md = e.mimeData()
1001
+ if md.hasFormat(MIME_CMD):
1002
+ payload = _unpack_cmd_safely(bytes(md.data(MIME_CMD)))
1003
+ if not isinstance(payload, dict) or not payload.get("command_id"):
1004
+ e.ignore(); return
1005
+ if payload.get("command_id") == "function_bundle":
1006
+ steps = payload.get("steps") or []
1007
+ self._append_steps(steps)
1008
+ else:
1009
+ self._append_steps([payload])
1010
+ e.acceptProposedAction(); return
1011
+ e.ignore()
1012
+
1013
+ # ---------- run / export ----------
1014
+ def _drag_bundle(self):
1015
+ payload = {"command_id": "function_bundle", "steps": self.current_steps()}
1016
+ md = QMimeData()
1017
+ md.setData(MIME_CMD, QByteArray(_pack_cmd_safely(payload)))
1018
+ drag = QDrag(self)
1019
+ drag.setMimeData(md)
1020
+ drag.setHotSpot(self.rect().center())
1021
+ drag.exec(Qt.DropAction.CopyAction)
1022
+
1023
+ def _apply_to_active_view(self):
1024
+ mw = _find_main_window(self)
1025
+ if not mw or not hasattr(mw, "_handle_command_drop"):
1026
+ QMessageBox.information(self, "Apply", "Main window not available.")
1027
+ return
1028
+ sw = mw.mdi.activeSubWindow() if hasattr(mw, "mdi") else None
1029
+ if not sw:
1030
+ QMessageBox.information(self, "Apply", "No active view.")
1031
+ return
1032
+ self._apply_steps_to_target_sw(mw, sw, self.current_steps())
1033
+
1034
+ def _apply_to_view_bundle(self):
1035
+ mw = _find_main_window(self)
1036
+ if not mw:
1037
+ QMessageBox.information(self, "Apply", "Main window not available.")
1038
+ return
1039
+
1040
+ settings = QSettings()
1041
+ settings.sync() # see latest saved bundles
1042
+
1043
+ raw_v2 = settings.value("viewbundles/v2", "", type=str)
1044
+ raw_v1 = settings.value("viewbundles/v1", "", type=str)
1045
+ raw = raw_v2 or raw_v1 or "[]"
1046
+
1047
+ try:
1048
+ vb_raw = json.loads(raw)
1049
+ except Exception:
1050
+ vb_raw = []
1051
+
1052
+ # normalize -> [(name, [int_ptr,...])]
1053
+ choices = []
1054
+ for b in vb_raw:
1055
+ if not isinstance(b, dict):
1056
+ continue
1057
+ name = (b.get("name") or "Bundle").strip()
1058
+ ptrs = []
1059
+ for x in (b.get("doc_ptrs") or []):
1060
+ try:
1061
+ ptrs.append(int(x))
1062
+ except Exception:
1063
+ pass
1064
+ choices.append((name, ptrs))
1065
+
1066
+ if not choices:
1067
+ QMessageBox.information(self, "Apply", "No View Bundles found.")
1068
+ return
1069
+
1070
+ # ✅ create the dialog BEFORE using it
1071
+ dlg = QDialog(self)
1072
+ dlg.setWindowTitle("Apply to View Bundle…")
1073
+ v = QVBoxLayout(dlg)
1074
+ v.addWidget(QLabel("Select a View Bundle:"))
1075
+ lb = QListWidget(); v.addWidget(lb, 1)
1076
+ for name, ptrs in choices:
1077
+ it = QListWidgetItem(f"{name} ({len(ptrs)} views)")
1078
+ it.setData(Qt.ItemDataRole.UserRole, ptrs)
1079
+ lb.addItem(it)
1080
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
1081
+ v.addWidget(buttons)
1082
+ buttons.accepted.connect(dlg.accept); buttons.rejected.connect(dlg.reject)
1083
+
1084
+ if dlg.exec() != QDialog.DialogCode.Accepted:
1085
+ return
1086
+ cur = lb.currentItem()
1087
+ if not cur:
1088
+ return
1089
+ ptrs = cur.data(Qt.ItemDataRole.UserRole) or []
1090
+ steps = self.current_steps()
1091
+ if not steps:
1092
+ QMessageBox.information(self, "Apply", "This Function Bundle is empty.")
1093
+ return
1094
+
1095
+ # show busy cursor during batch apply
1096
+ try: QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor)
1097
+ except Exception as e:
1098
+ import logging
1099
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1100
+
1101
+ applied = 0
1102
+ for p in ptrs:
1103
+ _doc, sw = _resolve_doc_and_subwindow(mw, p)
1104
+ if sw is None:
1105
+ self._pump_events(0)
1106
+ continue
1107
+ _activate_target_sw(mw, sw)
1108
+ self._apply_steps_to_target_sw(mw, sw, steps)
1109
+ applied += 1
1110
+ self._wait_for_cosmicclarity(mw)
1111
+ self._pump_events(0)
1112
+
1113
+ try: QApplication.restoreOverrideCursor()
1114
+ except Exception as e:
1115
+ import logging
1116
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1117
+
1118
+ if applied == 0:
1119
+ QMessageBox.information(self, "Apply", "No valid targets in the selected bundle.")
1120
+
1121
+
1122
+ def _apply_steps_to_target_sw(self, mw, sw, steps: list[dict]):
1123
+ # local logger
1124
+ def _fb(msg: str):
1125
+ m = f"[FunctionBundleDialog] {msg}"
1126
+ try:
1127
+ # main window logger if present
1128
+ if hasattr(mw, "_log"):
1129
+ mw._log(m)
1130
+ except Exception:
1131
+ pass
1132
+ try:
1133
+ print(m, flush=True)
1134
+ except Exception:
1135
+ pass
1136
+
1137
+ _fb(f"ENTER _apply_steps_to_target_sw: sw={repr(sw)}, steps={len(steps)}")
1138
+
1139
+ errors = []
1140
+ total = len(steps)
1141
+
1142
+ # busy cursor while running this set
1143
+ try:
1144
+ QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor)
1145
+ except Exception:
1146
+ pass
1147
+
1148
+ # start fresh
1149
+ self._progress_reset()
1150
+
1151
+ for i, st in enumerate(steps, start=1):
1152
+ _activate_target_sw(mw, sw)
1153
+
1154
+ label = self._step_label(st)
1155
+ self._progress_set_step(i - 1, total, label)
1156
+
1157
+ if not isinstance(st, dict) or not st.get("command_id"):
1158
+ _fb(f" skip step[{i}]: invalid payload={repr(st)}")
1159
+ continue
1160
+
1161
+ cid = st.get("command_id")
1162
+ if str(cid).lower().startswith("cosmic"):
1163
+ _fb(f" >>> BEGIN CC step[{i}/{total}] cid={cid} payload={repr(st)}")
1164
+ else:
1165
+ _fb(f" BEGIN step[{i}/{total}] cid={cid} payload={repr(st)}")
1166
+
1167
+ try:
1168
+ mw._handle_command_drop(st, target_sw=sw)
1169
+
1170
+ if str(cid).lower().startswith("cosmic"):
1171
+ _fb(f" <<< END CC step[{i}/{total}] cid={cid} OK")
1172
+ else:
1173
+ _fb(f" END step[{i}/{total}] cid={cid} OK")
1174
+
1175
+ except Exception as e:
1176
+ errors.append(str(e))
1177
+ if str(cid).lower().startswith("cosmic"):
1178
+ _fb(f" <<< END CC step[{i}/{total}] cid={cid} ERROR: {e!r}")
1179
+ else:
1180
+ _fb(f" END step[{i}/{total}] cid={cid} ERROR: {e!r}")
1181
+
1182
+ self._progress_set_step(i, total, label)
1183
+ self._pump_events(0)
1184
+
1185
+ try:
1186
+ QApplication.restoreOverrideCursor()
1187
+ except Exception:
1188
+ pass
1189
+
1190
+ self.run_status.setText("Done.")
1191
+ self.run_progress.setRange(0, 100)
1192
+ self.run_progress.setValue(100)
1193
+
1194
+ if errors:
1195
+ _fb(f"EXIT with errors: {errors}")
1196
+ QMessageBox.warning(
1197
+ self,
1198
+ "Apply",
1199
+ "Some steps failed:\n\n" + "\n".join(errors),
1200
+ )
1201
+ else:
1202
+ _fb("EXIT OK (no errors)")
1203
+
1204
+
1205
+ def _compress_to_chip(self):
1206
+ i = self._current_index()
1207
+ if i < 0: return
1208
+ name = self._bundles[i].get("name", "Function Bundle")
1209
+
1210
+ mw = _find_main_window(self)
1211
+ if not mw:
1212
+ QMessageBox.information(self, "Compress", "Main window not available."); return
1213
+
1214
+ chip = self._chips.get(i)
1215
+ if chip is None or chip.parent() is None:
1216
+ chip = _spawn_function_chip_on_canvas(mw, self, name, bundle_key=f"fn-{i}")
1217
+ if chip is None:
1218
+ QMessageBox.information(self, "Compress", "Shortcut canvas not available."); return
1219
+ self._chips[i] = chip
1220
+
1221
+ # Ensure chip knows which bundle it represents
1222
+ try:
1223
+ chip.set_bundle_index(i)
1224
+ except Exception:
1225
+ pass
1226
+
1227
+ chip._title.setText(name)
1228
+ chip._sync_count()
1229
+ chip.show()
1230
+ chip.raise_()
1231
+
1232
+ # keep the panel visible (matches View Bundle behavior)
1233
+ try:
1234
+ self._save_chip_layout() # <── persist chip presence/pos
1235
+ except Exception:
1236
+ pass
1237
+
1238
+ def steps_for_index(self, idx: int) -> list[dict]:
1239
+ if 0 <= idx < len(self._bundles):
1240
+ return list(self._bundles[idx].get("steps") or [])
1241
+ return []
1242
+
1243
+ def step_count_for_index(self, idx: int) -> int:
1244
+ if 0 <= idx < len(self._bundles):
1245
+ return len(self._bundles[idx].get("steps") or [])
1246
+ return 0
1247
+
1248
+
1249
+ def closeEvent(self, e: QCloseEvent):
1250
+ super().closeEvent(e)
1251
+
1252
+ # ---------- script / command entry point ----------
1253
+
1254
+
1255
+ class FunctionBundleManager:
1256
+ """
1257
+ Simple QSettings-backed store for Function Bundles.
1258
+
1259
+ This MUST use the same "functionbundles/v1" key that the dialog uses,
1260
+ so scripts and UI always see the same bundles.
1261
+ """
1262
+ SETTINGS_KEY = "functionbundles/v1"
1263
+
1264
+ def __init__(self, app=None):
1265
+ # app is unused for now but kept for future (e.g. per-profile settings).
1266
+ self._settings = QSettings()
1267
+
1268
+ # ---- low-level ----
1269
+ def _load_all(self) -> list[dict]:
1270
+ raw = self._settings.value(self.SETTINGS_KEY, "[]", type=str)
1271
+ try:
1272
+ bundles = json.loads(raw)
1273
+ except Exception:
1274
+ bundles = []
1275
+
1276
+ if not isinstance(bundles, list):
1277
+ return []
1278
+ return [b for b in bundles if isinstance(b, dict)]
1279
+
1280
+ # ---- public API ----
1281
+ def list_bundles(self) -> list[dict]:
1282
+ return self._load_all()
1283
+
1284
+ def get_bundle(self, name: str) -> dict | None:
1285
+ if not name:
1286
+ return None
1287
+ want = name.strip().lower()
1288
+ for b in self._load_all():
1289
+ n = (b.get("name") or "").strip().lower()
1290
+ if n == want:
1291
+ return b
1292
+ return None
1293
+
1294
+
1295
+ # Optional: cache a single instance per process
1296
+ _bundle_mgr: FunctionBundleManager | None = None
1297
+
1298
+ def get_bundle_manager(app=None) -> FunctionBundleManager:
1299
+ """
1300
+ Return a process-wide FunctionBundleManager.
1301
+
1302
+ Keeping a single instance avoids re-parsing JSON constantly,
1303
+ but still reads from QSettings each time you call list/get.
1304
+ """
1305
+ global _bundle_mgr
1306
+ if _bundle_mgr is None:
1307
+ _bundle_mgr = FunctionBundleManager(app)
1308
+ return _bundle_mgr
1309
+
1310
+ # ---------- script / command entry point ----------
1311
+ def _normalize_steps_for_hcd(steps: list[Any]) -> list[Dict[str, Any]]:
1312
+ """
1313
+ Take whatever is stored in the bundle and normalize it into the
1314
+ drop-payload shape that MainWindow._handle_command_drop expects:
1315
+
1316
+ {
1317
+ "command_id": "<cid>",
1318
+ "preset": { ...optional... },
1319
+ "on_base": bool,
1320
+ ... (other keys passed through as-is)
1321
+ }
1322
+
1323
+ This keeps old bundles (with 'id' or 'cid' fields) working.
1324
+ """
1325
+ out: list[Dict[str, Any]] = []
1326
+
1327
+ for st in steps or []:
1328
+ if not isinstance(st, dict):
1329
+ continue
1330
+
1331
+ cid = (
1332
+ st.get("command_id")
1333
+ or st.get("cid")
1334
+ or st.get("id")
1335
+ )
1336
+ if not cid:
1337
+ continue
1338
+
1339
+ payload: Dict[str, Any] = {
1340
+ "command_id": cid,
1341
+ }
1342
+
1343
+ # Preserve preset if present
1344
+ if "preset" in st:
1345
+ payload["preset"] = st["preset"]
1346
+
1347
+ # Preserve on_base if present
1348
+ if "on_base" in st:
1349
+ payload["on_base"] = bool(st.get("on_base"))
1350
+
1351
+ # Keep label / description for logging / UI if you want
1352
+ if "label" in st:
1353
+ payload["label"] = st["label"]
1354
+
1355
+ # Pass through any extra keys you want HCD to see
1356
+ for k, v in st.items():
1357
+ if k in payload:
1358
+ continue
1359
+ if k in ("command_id", "cid", "id"):
1360
+ continue
1361
+ payload[k] = v
1362
+
1363
+ out.append(payload)
1364
+
1365
+ return out
1366
+
1367
+
1368
+
1369
+ def run_function_bundle_command(ctx, preset: dict | None = None):
1370
+ """
1371
+ Entry point for CommandSpec(id="function_bundle").
1372
+
1373
+ IMPORTANT:
1374
+ This is meant to behave EXACTLY like dropping a Function Bundle
1375
+ on a view in the UI. That means we DO NOT iterate steps via
1376
+ ctx.run_command; instead we synthesize a single payload with
1377
+ command_id='function_bundle' and let MainWindow._handle_command_drop
1378
+ do all the work.
1379
+ """
1380
+ preset = dict(preset or {})
1381
+
1382
+ app = getattr(ctx, "app", None) or getattr(ctx, "main_window", lambda: None)()
1383
+ if app is None:
1384
+ raise RuntimeError("Function Bundle command requires a GUI main window / ctx.app")
1385
+
1386
+ # --- resolve steps: saved bundle OR inline ---
1387
+ bundle_name = preset.get("bundle_name") or preset.get("name")
1388
+ steps: list[dict[str, Any]] = list(preset.get("steps") or [])
1389
+ inherit = bool(preset.get("inherit_target", True))
1390
+
1391
+ # optional: targets='all_open' or [doc_ptrs], same as HCD branch supports
1392
+ targets = preset.get("targets", None)
1393
+
1394
+ if bundle_name and not steps:
1395
+ # Use the same bundle store as the Function Bundles dialog
1396
+ mgr = get_bundle_manager(app)
1397
+ data = mgr.get_bundle(bundle_name)
1398
+ if not data:
1399
+ raise RuntimeError(f"Function Bundle '{bundle_name}' not found.")
1400
+ steps = list(data.get("steps") or [])
1401
+
1402
+ steps = _normalize_steps_for_hcd(steps)
1403
+
1404
+ if not steps:
1405
+ try:
1406
+ ctx.log("Function Bundle: no steps to run.")
1407
+ except Exception:
1408
+ pass
1409
+ return
1410
+
1411
+ # --- build the same payload the UI uses for a bundle drop ---
1412
+ payload: Dict[str, Any] = {
1413
+ "command_id": "function_bundle",
1414
+ "steps": steps,
1415
+ "inherit_target": inherit,
1416
+ }
1417
+ if targets is not None:
1418
+ payload["targets"] = targets
1419
+
1420
+ # If targets were specified, we mimic dropping on the background:
1421
+ # _handle_command_drop(payload, target_sw=None)
1422
+ # so the HCD branch fans out to all_open / explicit ptr list.
1423
+ if targets is not None:
1424
+ target_sw = None
1425
+ else:
1426
+ # "Normal" script usage: run on the active view, exactly like
1427
+ # dragging the bundle chip onto that view.
1428
+ try:
1429
+ target_sw = ctx.active_subwindow()
1430
+ except Exception:
1431
+ target_sw = None
1432
+
1433
+ if target_sw is None and targets is None:
1434
+ # No active view and no explicit targets – nothing to do.
1435
+ raise RuntimeError("Function Bundle: no active view and no explicit targets.")
1436
+
1437
+ # --- delegate to main-window drop handler (single point of truth) ---
1438
+ print(
1439
+ f"[FunctionBundle] Script call → _handle_command_drop() "
1440
+ f"inherit_target={inherit}, targets={targets!r}, steps={len(steps)}",
1441
+ flush=True,
1442
+ )
1443
+ QApplication.processEvents()
1444
+ app._handle_command_drop(payload, target_sw=target_sw)
1445
+ QApplication.processEvents()
1446
+
1447
+
1448
+ # ---------- singleton open helper ----------
1449
+ _dialog_singleton: FunctionBundleDialog | None = None
1450
+ def show_function_bundles(parent: QWidget | None,
1451
+ focus_name: str | None = None,
1452
+ *,
1453
+ auto_spawn_only: bool = False):
1454
+ """
1455
+ Open (or focus) the Function Bundles dialog.
1456
+
1457
+ If auto_spawn_only=True, ensure the dialog + chips exist,
1458
+ but do NOT show the dialog (for startup chip restore).
1459
+ """
1460
+ global _dialog_singleton
1461
+ if _dialog_singleton is None:
1462
+ _dialog_singleton = FunctionBundleDialog(parent)
1463
+ def _clear():
1464
+ global _dialog_singleton
1465
+ _dialog_singleton = None
1466
+ _dialog_singleton.destroyed.connect(_clear)
1467
+
1468
+ if focus_name:
1469
+ ...
1470
+
1471
+ if not auto_spawn_only:
1472
+ _dialog_singleton.show()
1473
+ _dialog_singleton.raise_()
1474
+ _dialog_singleton.activateWindow()
1475
+ return _dialog_singleton
1476
+
1477
+ def restore_function_bundle_chips(parent: QWidget | None):
1478
+ """
1479
+ Called at app startup: create the FunctionBundleDialog singleton,
1480
+ restore any saved chips onto the ShortcutCanvas, but keep the
1481
+ dialog itself hidden.
1482
+ """
1483
+ try:
1484
+ show_function_bundles(parent, auto_spawn_only=True)
1485
+ except Exception:
1486
+ pass
1487
+
1488
+ def export_function_bundles_payload() -> dict:
1489
+ """
1490
+ Export function bundle definitions + chip layout so they can be embedded
1491
+ into a shortcuts .sass file. This works even if the dialog isn't open.
1492
+ """
1493
+ s = QSettings()
1494
+ raw_bundles = s.value(FunctionBundleDialog.SETTINGS_KEY, "[]", type=str)
1495
+ raw_chips = s.value(FunctionBundleDialog.CHIP_KEY, "[]", type=str)
1496
+
1497
+ try:
1498
+ bundles = json.loads(raw_bundles)
1499
+ except Exception:
1500
+ bundles = []
1501
+ try:
1502
+ chips = json.loads(raw_chips)
1503
+ except Exception:
1504
+ chips = []
1505
+
1506
+ if not isinstance(bundles, list):
1507
+ bundles = []
1508
+ if not isinstance(chips, list):
1509
+ chips = []
1510
+
1511
+ # `bundles` contains full guts: name + steps (+ presets)
1512
+ # `chips` contains chip positions keyed by bundle index
1513
+ return {
1514
+ "bundles": bundles,
1515
+ "chips": chips,
1516
+ }
1517
+
1518
+ def import_function_bundles_payload(payload: dict, parent: QWidget | None, replace_existing: bool = False):
1519
+ """
1520
+ Apply imported bundle+chip payload from a .sass file.
1521
+
1522
+ - If replace_existing=True, overwrite existing bundles/chips.
1523
+ - If False, append to existing bundles and offset chip indices accordingly.
1524
+ """
1525
+ if not isinstance(payload, dict):
1526
+ return
1527
+
1528
+ new_bundles = payload.get("bundles") or []
1529
+ new_chips = payload.get("chips") or []
1530
+
1531
+ if not isinstance(new_bundles, list):
1532
+ new_bundles = []
1533
+ if not isinstance(new_chips, list):
1534
+ new_chips = []
1535
+
1536
+ s = QSettings()
1537
+
1538
+ if replace_existing:
1539
+ bundles = new_bundles
1540
+ chips = new_chips
1541
+ else:
1542
+ raw_b = s.value(FunctionBundleDialog.SETTINGS_KEY, "[]", type=str)
1543
+ raw_c = s.value(FunctionBundleDialog.CHIP_KEY, "[]", type=str)
1544
+ try:
1545
+ old_bundles = json.loads(raw_b)
1546
+ except Exception:
1547
+ old_bundles = []
1548
+ try:
1549
+ old_chips = json.loads(raw_c)
1550
+ except Exception:
1551
+ old_chips = []
1552
+
1553
+ if not isinstance(old_bundles, list):
1554
+ old_bundles = []
1555
+ if not isinstance(old_chips, list):
1556
+ old_chips = []
1557
+
1558
+ offset = len(old_bundles)
1559
+ bundles = old_bundles + new_bundles
1560
+
1561
+ chips = list(old_chips)
1562
+ for entry in new_chips:
1563
+ if not isinstance(entry, dict):
1564
+ continue
1565
+ try:
1566
+ idx = int(entry.get("index", -1))
1567
+ except Exception:
1568
+ continue
1569
+ if idx < 0:
1570
+ continue
1571
+ chips.append({
1572
+ "index": offset + idx,
1573
+ "x": entry.get("x"),
1574
+ "y": entry.get("y"),
1575
+ })
1576
+
1577
+ try:
1578
+ s.setValue(FunctionBundleDialog.SETTINGS_KEY, json.dumps(bundles, ensure_ascii=False))
1579
+ s.setValue(FunctionBundleDialog.CHIP_KEY, json.dumps(chips, ensure_ascii=False))
1580
+ s.sync()
1581
+ except Exception:
1582
+ pass
1583
+
1584
+ # Refresh any live dialog or, if none, spawn chips from settings
1585
+ from typing import cast
1586
+ global _dialog_singleton
1587
+ if _dialog_singleton is not None:
1588
+ try:
1589
+ cast(FunctionBundleDialog, _dialog_singleton).reload_from_settings_after_import()
1590
+ except Exception:
1591
+ pass
1592
+ else:
1593
+ try:
1594
+ restore_function_bundle_chips(parent)
1595
+ except Exception:
1596
+ pass