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