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,1558 @@
1
+ # pro/view_bundle.py
2
+ from __future__ import annotations
3
+ import json
4
+ import uuid
5
+ import os
6
+ from typing import Iterable, Optional
7
+ import sys
8
+ from PyQt6.QtCore import Qt, QSettings, QByteArray, QMimeData, QSize, QPoint, QEventLoop
9
+ from PyQt6.QtWidgets import (
10
+ QDialog, QWidget, QHBoxLayout, QVBoxLayout, QListWidget, QListWidgetItem,QApplication,
11
+ QPushButton, QSplitter, QLabel, QAbstractItemView, QDialogButtonBox,
12
+ QCheckBox, QFrame, QSizePolicy, QMenu, QInputDialog, QFileDialog
13
+ )
14
+ import traceback
15
+ from PyQt6.QtWidgets import QMessageBox as _QMB
16
+ from PyQt6.QtGui import QDrag, QCloseEvent, QCursor, QShortcut, QKeySequence
17
+ from setiastro.saspro.legacy.image_manager import load_image, save_image
18
+ from setiastro.saspro.dnd_mime import MIME_CMD, MIME_VIEWSTATE
19
+ from setiastro.saspro.doc_manager import ImageDocument
20
+
21
+ def _pin_on_top_mac(win: QDialog):
22
+ if sys.platform == "darwin":
23
+ # Float above normal windows, behave like a palette/tool window
24
+ win.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
25
+ win.setWindowFlag(Qt.WindowType.Tool, True)
26
+ # Keep showing even when app deactivates (mac-only attribute)
27
+ try:
28
+ win.setAttribute(Qt.WidgetAttribute.WA_MacAlwaysShowToolWindow, True)
29
+ except Exception:
30
+ pass
31
+
32
+ # ---------- helpers ----------
33
+ def _find_main_window(w: QWidget):
34
+ p = w.parent()
35
+ # the main window has either .doc_manager or .docman
36
+ while p is not None and not (hasattr(p, "doc_manager") or hasattr(p, "docman")):
37
+ p = p.parent()
38
+ return p
39
+
40
+
41
+ def _resolve_doc_and_subwindow(mw, doc_ptr):
42
+ """
43
+ Resolve a (doc, sw) pair given the id(ptr) of the document.
44
+ Prefers the main-window helper if available; otherwise, scans open subwindows.
45
+ """
46
+ if hasattr(mw, "_find_doc_by_id"):
47
+ doc, sw = mw._find_doc_by_id(doc_ptr)
48
+ if doc is not None:
49
+ return doc, sw
50
+
51
+ # fallback: scan MDI
52
+ try:
53
+ for sw in mw.mdi.subWindowList():
54
+ vw = sw.widget()
55
+ d = getattr(vw, "document", None)
56
+ if d is not None and id(d) == int(doc_ptr):
57
+ return d, sw
58
+ except Exception:
59
+ pass
60
+ return None, None
61
+
62
+
63
+ def _unpack_cmd_safely(raw: bytes):
64
+ """
65
+ Lazy-import the real unpacker to avoid circular imports.
66
+ Fallback to JSON if needed.
67
+ """
68
+ try:
69
+ from setiastro.saspro.shortcuts import _unpack_cmd_payload as _unpack
70
+ except Exception:
71
+ _unpack = None
72
+
73
+ if _unpack is not None:
74
+ try:
75
+ return _unpack(raw)
76
+ except Exception:
77
+ pass
78
+ # Fallback: assume JSON
79
+ try:
80
+ return json.loads(raw.decode("utf-8"))
81
+ except Exception:
82
+ return None
83
+
84
+
85
+ def _pack_cmd_safely(payload: dict) -> bytes:
86
+ """
87
+ Lazy-import the real packer if available, otherwise JSON-encode.
88
+ """
89
+ try:
90
+ from setiastro.saspro.shortcuts import _pack_cmd_payload as _PACK
91
+ except Exception:
92
+ _PACK = None
93
+
94
+ if _PACK:
95
+ data = _PACK(payload)
96
+ return bytes(data) if not isinstance(data, (bytes, bytearray)) else data
97
+ return json.dumps(payload).encode("utf-8")
98
+
99
+
100
+ def _find_shortcut_canvas(mw: QWidget | None) -> QWidget | None:
101
+ if not mw:
102
+ return None
103
+ canv = getattr(getattr(mw, "shortcuts", None), "canvas", None)
104
+ if canv:
105
+ return canv
106
+ try:
107
+ from setiastro.saspro.shortcuts import ShortcutCanvas
108
+ return mw.findChild(ShortcutCanvas)
109
+ except Exception:
110
+ return None
111
+
112
+ def _unwrap_cmd_payload(p: dict) -> dict:
113
+ """
114
+ Some packers wrap as {'command_id': {actual_cmd_dict}, 'preset': {...}}.
115
+ If we see that shape, return the inner dict.
116
+ """
117
+ if isinstance(p, dict):
118
+ cmd = p.get("command_id")
119
+ if isinstance(cmd, dict) and cmd.get("command_id"):
120
+ return dict(cmd) # copy to avoid aliasing
121
+ return p
122
+
123
+ # ----------------------------- Bundle Chip -----------------------------
124
+ class BundleChip(QWidget):
125
+ """
126
+ A movable chip displayed on the ShortcutCanvas.
127
+
128
+ Behaviors:
129
+ - Left-drag: move inside the canvas
130
+ - Ctrl+drag: start external DnD with MIME_CMD payload (command_id="bundle")
131
+ - Drop a view (MIME_VIEWSTATE): add that view to this bundle
132
+ - Drop a shortcut (MIME_CMD): apply that shortcut to all views in the bundle
133
+ - Double-click: re-open the View Bundle dialog (event is accepted so it won't propagate)
134
+
135
+ Each chip is bound to ONE bundle via a persistent UUID.
136
+ """
137
+ def __init__(self, panel: "ViewBundleDialog", bundle_uuid: str, name: str,
138
+ steps: list | None = None, parent: QWidget | None = None):
139
+ super().__init__(parent)
140
+ self._panel = panel
141
+ self._bundle_uuid = bundle_uuid
142
+ self._name = name
143
+ self._steps = steps or [] # optional future use (not required now)
144
+
145
+ self.setAcceptDrops(True)
146
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # ← so Delete/Backspace work
147
+
148
+ self.setObjectName("BundleChip")
149
+ self.setMinimumSize(160, 38)
150
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
151
+ self.setStyleSheet("""
152
+ QWidget#BundleChip {
153
+ background: rgba(60, 60, 70, 200);
154
+ border: 1px solid rgba(220,220,220,64);
155
+ border-radius: 8px;
156
+ }
157
+ QLabel#chipTitle {
158
+ padding: 6px 10px 2px 10px;
159
+ color: #e6e6e6;
160
+ font-weight: 600;
161
+ }
162
+ QLabel#chipHint {
163
+ padding: 0 10px 6px 10px;
164
+ color: #bdbdbd;
165
+ font-size: 11px;
166
+ }
167
+ QWidget#BundleChip:hover {
168
+ border-color: rgba(255,255,255,128);
169
+ }
170
+ """)
171
+
172
+ v = QVBoxLayout(self)
173
+ v.setContentsMargins(0, 0, 0, 0)
174
+ v.setSpacing(0)
175
+ self._title = QLabel(self._name)
176
+ self._title.setObjectName("chipTitle")
177
+ self._hint = QLabel("Drag to move · Ctrl+drag to apply · Drop views/shortcuts here")
178
+ self._hint.setObjectName("chipHint")
179
+ v.addWidget(self._title, 0, Qt.AlignmentFlag.AlignCenter)
180
+ v.addWidget(self._hint, 0, Qt.AlignmentFlag.AlignCenter)
181
+
182
+ self._press_pos: QPoint | None = None
183
+ self._moving = False
184
+ self._grab_offset = None
185
+ self._dragging = False
186
+
187
+ # --- data binding ---
188
+ @property
189
+ def bundle_uuid(self) -> str:
190
+ return self._bundle_uuid
191
+
192
+ def sync_from_panel(self):
193
+ b = self._panel._get_bundle(self._bundle_uuid)
194
+ if b:
195
+ self._name = b.get("name", "Bundle")
196
+ self._title.setText(self._name)
197
+
198
+ # --- movement inside canvas / external DnD ---
199
+ def mousePressEvent(self, ev):
200
+ if ev.button() == Qt.MouseButton.LeftButton:
201
+ self.setFocus(Qt.FocusReason.MouseFocusReason) # ← focus for Delete key
202
+ # store where in the chip the user grabbed (widget-local)
203
+ self._grab_offset = ev.position() # QPointF
204
+ self._dragging = True
205
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
206
+ ev.accept() # stop propagation to canvas
207
+ return
208
+ super().mousePressEvent(ev)
209
+
210
+ def mouseMoveEvent(self, ev):
211
+ if not (ev.buttons() & Qt.MouseButton.LeftButton) or not getattr(self, "_dragging", False):
212
+ super().mouseMoveEvent(ev)
213
+ return
214
+
215
+ # Ctrl held → start external DnD once, not repeatedly
216
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
217
+ self._dragging = False
218
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
219
+ self._start_external_drag()
220
+ ev.accept()
221
+ return
222
+
223
+ # Anchor the chip to the cursor using GLOBAL coordinates
224
+ parent = self.parentWidget()
225
+ if not parent:
226
+ return
227
+
228
+ # where the cursor is globally, minus where we grabbed inside the chip
229
+ global_top_left = ev.globalPosition() - getattr(self, "_grab_offset", ev.position())
230
+ # convert that to the parent’s coordinate system
231
+ top_left = parent.mapFromGlobal(global_top_left.toPoint())
232
+
233
+ # clamp inside parent’s rect
234
+ max_x = max(0, parent.width() - self.width())
235
+ max_y = max(0, parent.height() - self.height())
236
+ x = min(max(0, top_left.x()), max_x)
237
+ y = min(max(0, top_left.y()), max_y)
238
+
239
+ self.move(x, y)
240
+ ev.accept() # don’t let the canvas also handle this drag
241
+
242
+ def mouseReleaseEvent(self, ev):
243
+ if ev.button() == Qt.MouseButton.LeftButton:
244
+ self._dragging = False
245
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
246
+ # persist chip positions when a drag finishes
247
+ try:
248
+ self._panel._save_chip_layout()
249
+ except Exception:
250
+ pass
251
+ ev.accept()
252
+ return
253
+ super().mouseReleaseEvent(ev)
254
+
255
+
256
+ def mouseDoubleClickEvent(self, ev):
257
+ # reopen the panel and STOP propagation so canvas double-click doesn't fire
258
+ try:
259
+ self._panel.showNormal()
260
+ self._panel.raise_()
261
+ self._panel.activateWindow()
262
+ except Exception:
263
+ pass
264
+ ev.accept()
265
+
266
+ def contextMenuEvent(self, ev):
267
+ m = QMenu(self)
268
+ act_del = m.addAction("Delete Chip")
269
+ act = m.exec(ev.globalPos())
270
+ if act is act_del:
271
+ try:
272
+ self._panel._remove_chip_widget(self)
273
+ except Exception:
274
+ pass
275
+ else:
276
+ ev.ignore()
277
+
278
+ def keyPressEvent(self, ev):
279
+ key = ev.key()
280
+ if key in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
281
+ try:
282
+ self._panel._remove_chip_widget(self)
283
+ except Exception:
284
+ pass
285
+ ev.accept()
286
+ return
287
+ super().keyPressEvent(ev)
288
+
289
+
290
+ def _start_external_drag(self):
291
+ # unchanged from your current version
292
+ payload = {"command_id": "bundle", "steps": self._steps, "bundle_uuid": self._bundle_uuid}
293
+ md = QMimeData()
294
+ md.setData(MIME_CMD, QByteArray(_pack_cmd_safely(payload)))
295
+ drag = QDrag(self)
296
+ drag.setMimeData(md)
297
+ drag.setHotSpot(QPoint(self.width() // 2, self.height() // 2))
298
+ drag.exec(Qt.DropAction.CopyAction)
299
+
300
+ # --- accept drops onto the chip ---
301
+ def dragEnterEvent(self, e):
302
+ if e.mimeData().hasFormat(MIME_VIEWSTATE) or e.mimeData().hasFormat(MIME_CMD):
303
+ e.acceptProposedAction()
304
+ else:
305
+ e.ignore()
306
+
307
+ def dropEvent(self, e):
308
+ md = e.mimeData()
309
+ # Add a view to this bundle
310
+ if md.hasFormat(MIME_VIEWSTATE):
311
+ try:
312
+ st = json.loads(bytes(md.data(MIME_VIEWSTATE)).decode("utf-8"))
313
+ doc_ptr = int(st.get("doc_ptr", 0))
314
+ if doc_ptr:
315
+ self._panel._add_doc_ptrs_to_uuid(self._bundle_uuid, [doc_ptr])
316
+ # if the panel is showing THIS bundle, refresh its list
317
+ self._panel._refresh_docs_list_if_current_uuid(self._bundle_uuid)
318
+ except Exception:
319
+ pass
320
+ e.acceptProposedAction()
321
+ return
322
+
323
+ if md.hasUrls():
324
+ paths = []
325
+ for url in md.urls():
326
+ p = url.toLocalFile()
327
+ if not p: continue
328
+ if os.path.isdir(p):
329
+ for r, d, files in os.walk(p):
330
+ for f in files:
331
+ if f.lower().endswith(tuple(x.lower() for x in self._panel._file_exts())):
332
+ paths.append(os.path.join(r, f))
333
+ else:
334
+ if p.lower().endswith(tuple(x.lower() for x in self._panel._file_exts())):
335
+ paths.append(p)
336
+ if paths:
337
+ self._panel._add_files_to_uuid(self._bundle_uuid, paths)
338
+ e.acceptProposedAction()
339
+ return
340
+
341
+ # Apply a shortcut to all views in this bundle
342
+ if md.hasFormat(MIME_CMD):
343
+ try:
344
+ payload = _unpack_cmd_safely(bytes(md.data(MIME_CMD)))
345
+ if payload is None:
346
+ raise ValueError("Unsupported shortcut payload format")
347
+ self._panel._apply_payload_to_bundle(payload, target_uuid=self._bundle_uuid)
348
+ e.acceptProposedAction()
349
+ return
350
+ except Exception as ex:
351
+ _QMB.warning(self, "Apply to Bundle", f"Could not parse/execute shortcut:\n{ex}")
352
+ e.ignore()
353
+
354
+
355
+ def spawn_bundle_chip_on_canvas(mw: QWidget, panel: "ViewBundleDialog",
356
+ bundle_uuid: str, name: str) -> BundleChip | None:
357
+ canvas = _find_shortcut_canvas(mw)
358
+ if not canvas:
359
+ return None
360
+
361
+ chip = BundleChip(panel, bundle_uuid, name, parent=canvas)
362
+ chip.resize(190, 46)
363
+
364
+ # place near cursor, clamped inside canvas
365
+ pt = canvas.mapFromGlobal(QCursor.pos()) - chip.rect().center()
366
+ pt.setX(max(0, min(pt.x(), canvas.width() - chip.width())))
367
+ pt.setY(max(0, min(pt.y(), canvas.height() - chip.height())))
368
+ chip.move(pt)
369
+ chip.show()
370
+ chip.raise_()
371
+ return chip
372
+
373
+
374
+ # ----------------------------- Select-Views Dialog -----------------------------
375
+ class SelectViewsDialog(QDialog):
376
+ """Simple checkbox picker of all open views."""
377
+ def __init__(self, parent: QWidget, choices: list[tuple[str, int]]):
378
+ super().__init__(parent)
379
+ self.setWindowTitle("Add Views to Bundle")
380
+ self.setWindowFlag(Qt.WindowType.Window, True)
381
+ self.setWindowModality(Qt.WindowModality.NonModal)
382
+ self.setModal(False)
383
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
384
+ self._boxes: list[QCheckBox] = []
385
+
386
+ v = QVBoxLayout(self)
387
+ v.addWidget(QLabel("Choose views to add:"))
388
+ v.setSpacing(6)
389
+
390
+ # NEW: "Select all" checkbox
391
+ self._select_all = QCheckBox("Select all open views")
392
+ self._select_all.toggled.connect(self._on_select_all_toggled)
393
+ v.addWidget(self._select_all)
394
+
395
+ box = QVBoxLayout()
396
+ cont = QWidget(); cont.setLayout(box)
397
+ cont.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.MinimumExpanding)
398
+
399
+ for title, ptr in choices:
400
+ cb = QCheckBox(f"{title}")
401
+ cb.setProperty("doc_ptr", int(ptr))
402
+ box.addWidget(cb)
403
+ self._boxes.append(cb)
404
+
405
+ box.addStretch(1)
406
+ frame = QFrame(); frame.setLayout(box)
407
+ v.addWidget(frame, 1)
408
+
409
+ buttons = QDialogButtonBox(
410
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
411
+ )
412
+ buttons.accepted.connect(self.accept)
413
+ buttons.rejected.connect(self.reject)
414
+ v.addWidget(buttons)
415
+
416
+ # NEW: handler for the "Select all" checkbox
417
+ def _on_select_all_toggled(self, checked: bool):
418
+ for cb in self._boxes:
419
+ cb.setChecked(checked)
420
+
421
+ def selected_ptrs(self) -> list[int]:
422
+ """Return list of doc_ptrs for checked boxes."""
423
+ return [int(cb.property("doc_ptr")) for cb in self._boxes if cb.isChecked()]
424
+
425
+
426
+
427
+ class _HeadlessView:
428
+ def __init__(self, doc, mw):
429
+ self.document = doc
430
+ self._mw = mw
431
+
432
+ def apply_command(self, command_id: str, preset: dict | None = None):
433
+ # Best-effort fallback if someone calls this directly.
434
+ preset = preset or {}
435
+ apply_to_view = getattr(self._mw, "apply_command_to_view", None)
436
+ if callable(apply_to_view):
437
+ return apply_to_view(self, command_id, preset)
438
+ # If nothing else exists, just no-op (don’t raise a user-facing error).
439
+ return None
440
+
441
+ class _FakeSubWindow:
442
+ """Headless stand-in that gives _handle_command_drop a .widget() with .document."""
443
+ def __init__(self, view):
444
+ self._view = view
445
+ def widget(self):
446
+ return self._view
447
+ def windowTitle(self):
448
+ # Try to mirror what a real subwindow title would show
449
+ try:
450
+ doc = getattr(self._view, "document", None)
451
+ if doc:
452
+ name = getattr(doc, "display_name", None)
453
+ if callable(name):
454
+ return name()
455
+ # common fallback attribute(s)
456
+ return getattr(doc, "name", None) or getattr(doc, "filename", None) or "view"
457
+ except Exception:
458
+ pass
459
+ return "view"
460
+
461
+
462
+ def _apply_one_shortcut_to_doc(mw, doc, payload: dict):
463
+ if not isinstance(payload, dict):
464
+ raise RuntimeError("Invalid shortcut payload")
465
+
466
+ cid = payload.get("command_id")
467
+ if isinstance(cid, dict):
468
+ payload = cid
469
+ cid = payload.get("command_id")
470
+ if not isinstance(cid, str) or not cid:
471
+ raise RuntimeError("Invalid command id")
472
+ if cid == "bundle":
473
+ return # ignore nested bundles
474
+
475
+ view = _HeadlessView(doc, mw)
476
+
477
+ # 1) Primary: same as canvas → ShortcutManager path
478
+ handle = getattr(mw, "_handle_command_drop", None)
479
+ if callable(handle):
480
+ # Pass a fake subwindow whose widget() returns our headless view
481
+ fake_sw = _FakeSubWindow(view)
482
+ handle(payload, target_sw=fake_sw)
483
+ return
484
+
485
+ # 2) Secondary: explicit apply-to-view hook
486
+ apply_to_view = getattr(mw, "apply_command_to_view", None)
487
+ if callable(apply_to_view):
488
+ apply_to_view(view, cid, payload.get("preset") or {})
489
+ return
490
+
491
+ # 3) Last-resort: let the shim try a no-op-safe apply_command
492
+ view.apply_command(cid, payload.get("preset") or {})
493
+
494
+
495
+
496
+ # ----------------------------- ViewBundleDialog -----------------------------
497
+ class ViewBundleDialog(QDialog):
498
+ """
499
+ Pure 'bundle of views' manager.
500
+ • Create many bundles (each with a persistent UUID)
501
+ • Drag a view (from ⧉ tab) → add to bundle
502
+ • Add from list of open views
503
+ • Drop a shortcut (MIME_CMD) onto the bundle/panel/chip → apply to all views in THAT bundle
504
+ • Compress → spawns a small Chip on the ShortcutCanvas that keeps accepting DnD
505
+ • Multiple chips at once (one per bundle)
506
+ """
507
+ SETTINGS_KEY = "viewbundles/v3" # bumped for uuid
508
+ CHIP_KEY = "viewbundles/chips_v1" # ← new: chip layout
509
+
510
+ def __init__(self, parent: QWidget | None = None):
511
+ super().__init__(parent)
512
+ _pin_on_top_mac(self)
513
+ self.setWindowTitle("View Bundles")
514
+ self.setWindowFlag(Qt.WindowType.Window, True)
515
+ self.setWindowModality(Qt.WindowModality.NonModal)
516
+ self.setModal(False)
517
+ self.resize(900, 540)
518
+ self.setAcceptDrops(True)
519
+
520
+ self._settings = QSettings()
521
+ self._bundles = self._load_all() # [{"uuid":str, "name":str, "doc_ptrs":[int,...]}]
522
+ if not self._bundles:
523
+ self._bundles = [{"uuid": self._new_uuid(), "name": "Bundle 1", "doc_ptrs": []}]
524
+
525
+ # UI
526
+ self.list = QListWidget()
527
+ self.list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
528
+ # rename UX
529
+ self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
530
+ self.list.customContextMenuRequested.connect(self._bundles_context_menu)
531
+ self.list.itemDoubleClicked.connect(lambda _it: self._rename_bundle())
532
+ QShortcut(QKeySequence("F2"), self.list, activated=self._rename_bundle)
533
+
534
+
535
+ self.docs = QListWidget()
536
+ self.docs.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
537
+ # Context menu + double-click niceties on the bundle's treebox/list
538
+ self.docs.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
539
+ self.docs.customContextMenuRequested.connect(self._docs_context_menu)
540
+ self.docs.itemDoubleClicked.connect(self._docs_item_activated)
541
+ self.btn_new = QPushButton("New Bundle")
542
+ self.btn_dup = QPushButton("Duplicate")
543
+ self.btn_del = QPushButton("Delete")
544
+ self.btn_clear = QPushButton("Clear Views")
545
+ self.btn_remove_sel = QPushButton("Remove Selected")
546
+ self.btn_add_from_open = QPushButton("Add from Open…")
547
+ self.btn_add_files = QPushButton("Add Files…")
548
+ self.btn_add_dir = QPushButton("Add Directory (Recursive)…")
549
+ self.btn_compress = QPushButton("Compress to Chip")
550
+ self.drop_hint = QLabel("Drop views here to add • Drop shortcuts here to apply to THIS bundle")
551
+ self.drop_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
552
+ self.drop_hint.setStyleSheet("color:#aaa; padding:6px; border:1px dashed #666; border-radius:6px;")
553
+
554
+ left = QVBoxLayout()
555
+ left.addWidget(QLabel("Bundles"))
556
+ left.addWidget(self.list, 1)
557
+ row = QHBoxLayout()
558
+ row.addWidget(self.btn_new); row.addWidget(self.btn_dup); row.addWidget(self.btn_del)
559
+ left.addLayout(row)
560
+
561
+ right = QVBoxLayout()
562
+ right.addWidget(QLabel("Views in Selected Bundle"))
563
+ right.addWidget(self.docs, 1)
564
+
565
+ rrow = QHBoxLayout()
566
+ rrow.addWidget(self.btn_add_from_open)
567
+ rrow.addStretch(1)
568
+ rrow.addWidget(self.btn_remove_sel)
569
+ rrow.addWidget(self.btn_clear)
570
+ right.addLayout(rrow)
571
+ rrow2 = QHBoxLayout()
572
+ rrow2.addWidget(self.btn_add_files)
573
+ rrow2.addWidget(self.btn_add_dir)
574
+ right.addLayout(rrow2)
575
+ right.addWidget(self.drop_hint)
576
+ right.addWidget(self.btn_compress)
577
+
578
+ split = QSplitter()
579
+ wl = QWidget(); wl.setLayout(left)
580
+ wr = QWidget(); wr.setLayout(right)
581
+ split.addWidget(wl); split.addWidget(wr)
582
+ split.setStretchFactor(0, 0)
583
+ split.setStretchFactor(1, 1)
584
+
585
+ root = QHBoxLayout(self)
586
+ root.addWidget(split)
587
+
588
+ # wiring
589
+ self.btn_new.clicked.connect(self._new_bundle)
590
+ self.btn_dup.clicked.connect(self._dup_bundle)
591
+ self.btn_del.clicked.connect(self._del_bundle)
592
+ self.btn_clear.clicked.connect(self._clear_bundle)
593
+ self.btn_remove_sel.clicked.connect(self._remove_selected)
594
+ self.btn_add_from_open.clicked.connect(self._add_from_open_picker)
595
+ self.btn_compress.clicked.connect(self._compress_to_chip)
596
+ self.list.currentRowChanged.connect(lambda _i: self._refresh_docs_list())
597
+ self.btn_add_files.clicked.connect(self._add_files_into_bundle)
598
+ self.btn_add_dir.clicked.connect(self._add_directory_into_bundle)
599
+ # populate
600
+ self._refresh_bundle_list()
601
+ if self.list.count():
602
+ self.list.setCurrentRow(0)
603
+
604
+ # chips by uuid
605
+ self._chips: dict[str, BundleChip] = {} # uuid -> chip widget
606
+
607
+ try:
608
+ self._restore_chips_from_settings()
609
+ except Exception:
610
+ pass
611
+
612
+ def _save_chip_layout(self):
613
+ """
614
+ Persist current bundle chips and their positions so they reappear
615
+ on the ShortcutCanvas next time SASpro is opened.
616
+ """
617
+ try:
618
+ data = []
619
+ for uuid, chip in list(self._chips.items()):
620
+ if chip is None or chip.parent() is None:
621
+ continue
622
+ pos = chip.pos()
623
+ data.append({
624
+ "uuid": str(uuid),
625
+ "x": int(pos.x()),
626
+ "y": int(pos.y()),
627
+ })
628
+ self._settings.setValue(self.CHIP_KEY, json.dumps(data, ensure_ascii=False))
629
+ self._settings.sync()
630
+ except Exception:
631
+ pass
632
+
633
+ def _restore_chips_from_settings(self):
634
+ """
635
+ Recreate chips on the ShortcutCanvas from saved layout.
636
+ Called on dialog init.
637
+ """
638
+ mw = _find_main_window(self)
639
+ if not mw:
640
+ return
641
+
642
+ raw = self._settings.value(self.CHIP_KEY, "[]", type=str)
643
+ try:
644
+ data = json.loads(raw)
645
+ except Exception:
646
+ data = []
647
+
648
+ if not isinstance(data, list):
649
+ return
650
+
651
+ for entry in data:
652
+ try:
653
+ u = str(entry.get("uuid", "")).strip()
654
+ except Exception:
655
+ continue
656
+ if not u:
657
+ continue
658
+
659
+ # must still exist as a bundle
660
+ b = self._get_bundle(u)
661
+ if not b:
662
+ continue
663
+
664
+ name = b.get("name", "Bundle")
665
+ chip = spawn_bundle_chip_on_canvas(mw, self, u, name)
666
+ if chip is None:
667
+ continue
668
+
669
+ x = entry.get("x")
670
+ y = entry.get("y")
671
+ if isinstance(x, int) and isinstance(y, int):
672
+ chip.move(x, y)
673
+
674
+ self._chips[u] = chip
675
+
676
+ def _remove_chip_widget(self, chip: BundleChip):
677
+ """
678
+ Remove a chip from the canvas and our uuid→chip registry,
679
+ without deleting the underlying bundle.
680
+ """
681
+ # drop from the mapping
682
+ for u, ch in list(self._chips.items()):
683
+ if ch is chip:
684
+ self._chips.pop(u, None)
685
+ break
686
+
687
+ try:
688
+ chip.setParent(None)
689
+ chip.deleteLater()
690
+ except Exception:
691
+ pass
692
+
693
+ self._save_chip_layout()
694
+
695
+
696
+ # ---------- persistence ----------
697
+ @staticmethod
698
+ def _new_uuid() -> str:
699
+ return str(uuid.uuid4())
700
+
701
+ def _ensure_uuid(self, b: dict):
702
+ if "uuid" not in b or not b["uuid"]:
703
+ b["uuid"] = self._new_uuid()
704
+
705
+ def _load_all(self):
706
+ raw = self._settings.value(self.SETTINGS_KEY, "[]", type=str)
707
+ try:
708
+ data = json.loads(raw)
709
+ if isinstance(data, list):
710
+ out = []
711
+ for b in data:
712
+ if not isinstance(b, dict):
713
+ continue
714
+ nm = (b.get("name") or "Bundle").strip()
715
+ ptrs = [int(x) for x in (b.get("doc_ptrs") or []) if isinstance(x, (int, str))]
716
+ fps = [str(p) for p in (b.get("file_paths") or []) if isinstance(p, (str,))]
717
+ u = b.get("uuid") or self._new_uuid()
718
+ out.append({"uuid": u, "name": nm, "doc_ptrs": ptrs, "file_paths": fps})
719
+ return out
720
+ except Exception:
721
+ pass
722
+ return []
723
+
724
+ def _save_all(self):
725
+ try:
726
+ # ensure keys exist
727
+ for b in self._bundles:
728
+ b.setdefault("doc_ptrs", [])
729
+ b.setdefault("file_paths", [])
730
+ self._settings.setValue(self.SETTINGS_KEY, json.dumps(self._bundles, ensure_ascii=False))
731
+ except Exception:
732
+ pass
733
+
734
+ def _file_exts(self):
735
+ return (".fits", ".fit", ".fts", ".fits.gz", ".fit.gz", ".fz", ".xisf", ".tif", ".tiff", ".png", ".jpg", ".jpeg")
736
+
737
+ def _add_files_into_bundle(self):
738
+ u = self._current_uuid()
739
+ if not u:
740
+ return
741
+ last_dir = QSettings().value("last_opened_folder", "", type=str)
742
+ files, _ = QFileDialog.getOpenFileNames(
743
+ self, "Select Files for Bundle", last_dir,
744
+ "Images (*.fits *.fit *.fts *.fits.gz *.fit.gz *.fz *.xisf *.tif *.tiff *.png *.jpg *.jpeg)"
745
+ )
746
+ if not files:
747
+ return
748
+ QSettings().setValue("last_opened_folder", os.path.dirname(files[0]))
749
+ # Dedup in bundle
750
+ self._add_files_to_uuid(u, files)
751
+
752
+ def _add_directory_into_bundle(self):
753
+ u = self._current_uuid()
754
+ if not u:
755
+ return
756
+ last_dir = QSettings().value("last_opened_folder", "", type=str)
757
+ directory = QFileDialog.getExistingDirectory(self, "Select Directory for Bundle", last_dir)
758
+ if not directory:
759
+ return
760
+ QSettings().setValue("last_opened_folder", directory)
761
+ exts = tuple(x.lower() for x in self._file_exts())
762
+ found = []
763
+ for root, dirs, files in os.walk(directory):
764
+ for f in files:
765
+ if f.lower().endswith(exts):
766
+ found.append(os.path.join(root, f))
767
+ if not found:
768
+ _QMB.information(self, "Add Directory", "No supported images found recursively.")
769
+ return
770
+ self._add_files_to_uuid(u, found)
771
+
772
+
773
+ # ---------- bundle lookups / edits ----------
774
+ def _current_index(self) -> int:
775
+ i = self.list.currentRow()
776
+ if i < 0 or i >= len(self._bundles): return -1
777
+ return i
778
+
779
+ def _current_bundle(self) -> Optional[dict]:
780
+ i = self._current_index()
781
+ return None if i < 0 else self._bundles[i]
782
+
783
+ def _current_uuid(self) -> Optional[str]:
784
+ b = self._current_bundle()
785
+ return None if not b else b.get("uuid")
786
+
787
+ def _get_bundle(self, bundle_uuid: str) -> Optional[dict]:
788
+ for b in self._bundles:
789
+ if b.get("uuid") == bundle_uuid:
790
+ # normalize keys
791
+ b.setdefault("doc_ptrs", [])
792
+ b.setdefault("file_paths", [])
793
+ return b
794
+ return None
795
+
796
+ def _rename_current_in_list(self, new_name: str):
797
+ i = self._current_index()
798
+ if i < 0: return
799
+ self._bundles[i]["name"] = (new_name or "Bundle").strip()
800
+ self._save_all()
801
+ self._refresh_bundle_list()
802
+ self.list.setCurrentRow(i)
803
+ # sync chip title if exists
804
+ u = self._bundles[i]["uuid"]
805
+ if u in self._chips:
806
+ self._chips[u].sync_from_panel()
807
+
808
+ def current_bundle_doc_ptrs(self) -> list[int]:
809
+ b = self._current_bundle()
810
+ return [] if not b else list(b.get("doc_ptrs", []))
811
+
812
+ def _set_bundle_ptrs_by_uuid(self, bundle_uuid: str, ptrs: Iterable[int]):
813
+ b = self._get_bundle(bundle_uuid)
814
+ if not b:
815
+ return
816
+ uniq = []
817
+ seen = set()
818
+ for p in ptrs:
819
+ p = int(p)
820
+ if p not in seen:
821
+ seen.add(p); uniq.append(p)
822
+ b["doc_ptrs"] = uniq
823
+ self._save_all()
824
+ # update chip title/count if needed
825
+ if bundle_uuid in self._chips:
826
+ self._chips[bundle_uuid].sync_from_panel()
827
+ # refresh docs if this bundle is selected
828
+ self._refresh_docs_list_if_current_uuid(bundle_uuid)
829
+
830
+ def _add_doc_ptrs_to_uuid(self, bundle_uuid: str, ptrs: Iterable[int]):
831
+ b = self._get_bundle(bundle_uuid)
832
+ if not b:
833
+ return
834
+ cur = list(b.get("doc_ptrs", []))
835
+ merged = cur + [int(p) for p in ptrs]
836
+ self._set_bundle_ptrs_by_uuid(bundle_uuid, merged)
837
+
838
+ def _set_current_bundle_ptrs(self, ptrs: Iterable[int]):
839
+ u = self._current_uuid()
840
+ if not u: return
841
+ self._set_bundle_ptrs_by_uuid(u, ptrs)
842
+
843
+ def _set_bundle_files_by_uuid(self, bundle_uuid: str, paths: Iterable[str]):
844
+ b = self._get_bundle(bundle_uuid)
845
+ if not b:
846
+ return
847
+ uniq = []
848
+ seen = set()
849
+ for p in paths:
850
+ p = str(p)
851
+ if p not in seen:
852
+ seen.add(p); uniq.append(p)
853
+ b["file_paths"] = uniq
854
+ self._save_all()
855
+ if bundle_uuid in self._chips:
856
+ self._chips[bundle_uuid].sync_from_panel()
857
+ self._refresh_docs_list_if_current_uuid(bundle_uuid)
858
+
859
+ def _add_files_to_uuid(self, bundle_uuid: str, paths: Iterable[str]):
860
+ b = self._get_bundle(bundle_uuid)
861
+ if not b:
862
+ return
863
+ cur = list(b.get("file_paths", []))
864
+ merged = cur + [str(p) for p in paths]
865
+ self._set_bundle_files_by_uuid(bundle_uuid, merged)
866
+
867
+ def current_bundle_file_paths(self) -> list[str]:
868
+ b = self._current_bundle()
869
+ if not b: return []
870
+ return list(b.get("file_paths", []))
871
+
872
+ # ---------- UI refresh ----------
873
+ def _refresh_bundle_list(self):
874
+ self.list.clear()
875
+ for b in self._bundles:
876
+ it = QListWidgetItem(b.get("name", "Bundle"))
877
+ self.list.addItem(it)
878
+ # keep selection reasonable
879
+ if self.list.count() and self.list.currentRow() < 0:
880
+ self.list.setCurrentRow(0)
881
+
882
+ # ---------- rename helpers ----------
883
+ def _rename_bundle(self):
884
+ i = self._current_index()
885
+ if i < 0:
886
+ return
887
+ cur = self._bundles[i]
888
+ new_name, ok = QInputDialog.getText(self, "Rename Bundle",
889
+ "New name:", text=cur.get("name","Bundle"))
890
+ if not ok:
891
+ return
892
+ self._rename_current_in_list(new_name)
893
+
894
+ def _bundles_context_menu(self, pos):
895
+ if self.list.count() == 0:
896
+ return
897
+ # focus the item under cursor (so rename/dup/delete applies to it)
898
+ it = self.list.itemAt(pos)
899
+ if it:
900
+ self.list.setCurrentItem(it)
901
+
902
+ m = QMenu(self)
903
+ act_ren = m.addAction("Rename…")
904
+ act_dup = m.addAction("Duplicate")
905
+ act_del = m.addAction("Delete")
906
+ chosen = m.exec(self.list.mapToGlobal(pos))
907
+ if chosen is act_ren:
908
+ self._rename_bundle()
909
+ elif chosen is act_dup:
910
+ self._dup_bundle()
911
+ elif chosen is act_del:
912
+ self._del_bundle()
913
+
914
+ def _refresh_docs_list_if_current_uuid(self, bundle_uuid: str):
915
+ if bundle_uuid and bundle_uuid == self._current_uuid():
916
+ self._refresh_docs_list()
917
+
918
+ def _refresh_docs_list(self):
919
+ self.docs.clear()
920
+ mw = _find_main_window(self)
921
+ # --- views ---
922
+ for p in self.current_bundle_doc_ptrs():
923
+ title = f"(unresolved) [{p}]"
924
+ if mw is not None:
925
+ d, sw = _resolve_doc_and_subwindow(mw, p)
926
+ if d is not None:
927
+ title = sw.windowTitle() if sw else (getattr(d, "display_name", lambda: "Untitled")())
928
+ it = QListWidgetItem(f"[view] {title}")
929
+ it.setData(Qt.ItemDataRole.UserRole, int(p))
930
+ it.setData(Qt.ItemDataRole.UserRole + 1, "view")
931
+ self.docs.addItem(it)
932
+ # --- files ---
933
+ for path in self.current_bundle_file_paths():
934
+ it = QListWidgetItem(f"[file] {path}")
935
+ it.setData(Qt.ItemDataRole.UserRole, path)
936
+ it.setData(Qt.ItemDataRole.UserRole + 1, "file")
937
+ self.docs.addItem(it)
938
+
939
+ # ---------- list niceties: context menu + double-click ----------
940
+ def _docs_item_kind_and_value(self, it):
941
+ """Return ('view'|'file', value) from a QListWidgetItem."""
942
+ if not it:
943
+ return None, None
944
+ kind = it.data(Qt.ItemDataRole.UserRole + 1)
945
+ val = it.data(Qt.ItemDataRole.UserRole)
946
+ return kind, val
947
+
948
+ def _docs_item_activated(self, it):
949
+ """Double-click action: open file, or focus view."""
950
+ kind, val = self._docs_item_kind_and_value(it)
951
+ if kind == "file":
952
+ self._open_file_in_new_view(str(val))
953
+ elif kind == "view":
954
+ self._focus_view_ptr(int(val))
955
+
956
+ def _docs_context_menu(self, pos):
957
+ if self.docs.count() == 0:
958
+ return
959
+ # Focus the item under the cursor so actions apply sensibly
960
+ it = self.docs.itemAt(pos)
961
+ if it:
962
+ it.setSelected(True)
963
+
964
+ # Gather selection breakdown
965
+ sel = [self.docs.item(i) for i in range(self.docs.count()) if self.docs.item(i).isSelected()]
966
+ file_items = [s for s in sel if self._docs_item_kind_and_value(s)[0] == "file"]
967
+ view_items = [s for s in sel if self._docs_item_kind_and_value(s)[0] == "view"]
968
+ if not file_items and not view_items:
969
+ return
970
+
971
+ m = QMenu(self)
972
+ act_open_files = act_focus_views = None
973
+ if file_items:
974
+ lab = "Open in New View" if len(file_items) == 1 else f"Open {len(file_items)} Files in New Views"
975
+ act_open_files = m.addAction(lab)
976
+ if view_items:
977
+ labv = "Focus View" if len(view_items) == 1 else f"Focus {len(view_items)} Views"
978
+ act_focus_views = m.addAction(labv)
979
+
980
+ chosen = m.exec(self.docs.mapToGlobal(pos))
981
+ if chosen is act_open_files:
982
+ for itf in file_items:
983
+ _, path = self._docs_item_kind_and_value(itf)
984
+ self._open_file_in_new_view(str(path))
985
+ elif chosen is act_focus_views:
986
+ for itv in view_items:
987
+ _, ptr = self._docs_item_kind_and_value(itv)
988
+ self._focus_view_ptr(int(ptr))
989
+
990
+ def _focus_view_ptr(self, doc_ptr: int):
991
+ mw = _find_main_window(self)
992
+ if mw is None:
993
+ return
994
+ doc, sw = _resolve_doc_and_subwindow(mw, doc_ptr)
995
+ if sw is None:
996
+ return
997
+ try:
998
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
999
+ mw.mdi.setActiveSubWindow(sw)
1000
+ w = getattr(sw, "widget", lambda: None)()
1001
+ if w:
1002
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1003
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1004
+ except Exception:
1005
+ pass
1006
+
1007
+ def _open_file_in_new_view(self, path: str):
1008
+ """Open a bundle-listed file into a brand-new view (no save/overwrite)."""
1009
+ mw = _find_main_window(self)
1010
+ if mw is None:
1011
+ _QMB.information(self, "Open", "Main window not available.")
1012
+ return
1013
+ try:
1014
+ sw = None
1015
+ opened_doc = None
1016
+ # Prefer docman API if present
1017
+ if hasattr(mw, "docman") and hasattr(mw.docman, "open_path"):
1018
+ opened_doc = mw.docman.open_path(path)
1019
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 120)
1020
+ if opened_doc is not None and hasattr(mw, "_find_doc_by_id"):
1021
+ _doc, sw = mw._find_doc_by_id(id(opened_doc))
1022
+ # Fallback to legacy open hook
1023
+ if sw is None:
1024
+ if hasattr(mw, "_open_image"):
1025
+ mw._open_image(path)
1026
+ else:
1027
+ raise RuntimeError("No file-open method found on main window")
1028
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 120)
1029
+ # best-effort: find by title tail match
1030
+ bn = os.path.basename(path)
1031
+ for cand in getattr(mw.mdi, "subWindowList", lambda: [])():
1032
+ if bn in cand.windowTitle():
1033
+ sw = cand
1034
+ break
1035
+ # Focus the new subwindow
1036
+ if sw is not None:
1037
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
1038
+ mw.mdi.setActiveSubWindow(sw)
1039
+ w = getattr(sw, "widget", lambda: None)()
1040
+ if w:
1041
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1042
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1043
+ except Exception as e:
1044
+ _QMB.warning(self, "Open", f"Could not open:\n{path}\n\n{e}")
1045
+
1046
+ # ---------- left controls ----------
1047
+ def _new_bundle(self):
1048
+ b = {"uuid": self._new_uuid(), "name": f"Bundle {len(self._bundles)+1}", "doc_ptrs": []}
1049
+ self._bundles.append(b)
1050
+ self._save_all(); self._refresh_bundle_list()
1051
+ self.list.setCurrentRow(self.list.count() - 1)
1052
+
1053
+ def _dup_bundle(self):
1054
+ i = self._current_index()
1055
+ if i < 0: return
1056
+ b = self._bundles[i]
1057
+ cp = {
1058
+ "uuid": self._new_uuid(),
1059
+ "name": f"{b.get('name','Bundle')} (copy)",
1060
+ "doc_ptrs": list(b.get("doc_ptrs", []))
1061
+ }
1062
+ self._bundles.insert(i + 1, cp)
1063
+ self._save_all(); self._refresh_bundle_list()
1064
+ self.list.setCurrentRow(i + 1)
1065
+
1066
+ def _del_bundle(self):
1067
+ i = self._current_index()
1068
+ if i < 0: return
1069
+ u = self._bundles[i].get("uuid")
1070
+ # remove chip for this bundle, if any
1071
+ ch = self._chips.pop(u, None)
1072
+ if ch:
1073
+ try:
1074
+ ch.setParent(None)
1075
+ ch.deleteLater()
1076
+ except Exception:
1077
+ pass
1078
+ del self._bundles[i]
1079
+ self._save_all()
1080
+ self._refresh_bundle_list()
1081
+ if self.list.count():
1082
+ self.list.setCurrentRow(min(i, self.list.count() - 1))
1083
+
1084
+ # update chip layout persistence
1085
+ try:
1086
+ self._save_chip_layout()
1087
+ except Exception:
1088
+ pass
1089
+
1090
+
1091
+ # ---------- right controls ----------
1092
+ def _clear_bundle(self):
1093
+ self._set_current_bundle_ptrs([])
1094
+ u = self._current_uuid()
1095
+ if u: self._set_bundle_files_by_uuid(u, [])
1096
+
1097
+ def _remove_selected(self):
1098
+ view_ptrs, file_paths = [], []
1099
+ for i in range(self.docs.count()):
1100
+ it = self.docs.item(i)
1101
+ if not it.isSelected():
1102
+ continue
1103
+ kind = it.data(Qt.ItemDataRole.UserRole + 1)
1104
+ if kind == "view":
1105
+ view_ptrs.append(int(it.data(Qt.ItemDataRole.UserRole)))
1106
+ elif kind == "file":
1107
+ file_paths.append(str(it.data(Qt.ItemDataRole.UserRole)))
1108
+
1109
+ if view_ptrs:
1110
+ remain = [p for p in self.current_bundle_doc_ptrs() if p not in set(view_ptrs)]
1111
+ self._set_current_bundle_ptrs(remain)
1112
+ if file_paths:
1113
+ remain = [p for p in self.current_bundle_file_paths() if p not in set(file_paths)]
1114
+ u = self._current_uuid()
1115
+ if u: self._set_bundle_files_by_uuid(u, remain)
1116
+
1117
+ def _add_from_open_picker(self):
1118
+ mw = _find_main_window(self)
1119
+ if mw is None:
1120
+ _QMB.information(self, "Add from Open", "Main window not available.")
1121
+ return
1122
+ choices: list[tuple[str, int]] = []
1123
+ for sw in mw.mdi.subWindowList():
1124
+ vw = sw.widget()
1125
+ d = getattr(vw, "document", None)
1126
+ if d is not None:
1127
+ choices.append((sw.windowTitle(), int(id(d))))
1128
+ if not choices:
1129
+ _QMB.information(self, "Add from Open", "No open views.")
1130
+ return
1131
+ dlg = SelectViewsDialog(self, choices)
1132
+ if dlg.exec() == QDialog.DialogCode.Accepted:
1133
+ u = self._current_uuid()
1134
+ if not u: return
1135
+ self._add_doc_ptrs_to_uuid(u, dlg.selected_ptrs())
1136
+
1137
+ def _compress_to_chip(self):
1138
+ b = self._current_bundle()
1139
+ if not b: return
1140
+ u = b["uuid"]; name = b.get("name", "Bundle")
1141
+
1142
+ mw = _find_main_window(self)
1143
+ if not mw:
1144
+ _QMB.information(self, "Compress", "Main window not available.")
1145
+ return
1146
+
1147
+ # If a chip for this bundle already exists, just show/raise it
1148
+ chip = self._chips.get(u)
1149
+ if chip is None or chip.parent() is None:
1150
+ chip = spawn_bundle_chip_on_canvas(mw, self, u, name)
1151
+ if chip is None:
1152
+ _QMB.information(self, "Compress", "Shortcut canvas not available.")
1153
+ return
1154
+ self._chips[u] = chip
1155
+ else:
1156
+ chip.sync_from_panel()
1157
+ chip.show()
1158
+ chip.raise_()
1159
+
1160
+ # persist chip presence/position
1161
+ try:
1162
+ self._save_chip_layout()
1163
+ except Exception:
1164
+ pass
1165
+
1166
+
1167
+ # ---------- DnD into the PANEL (applies to CURRENT bundle only) ----------
1168
+ def dragEnterEvent(self, e):
1169
+ md = e.mimeData()
1170
+ if md.hasFormat(MIME_VIEWSTATE) or md.hasFormat(MIME_CMD) or md.hasUrls():
1171
+ e.acceptProposedAction()
1172
+ else:
1173
+ e.ignore()
1174
+
1175
+ def dropEvent(self, e):
1176
+ md = e.mimeData()
1177
+ u = self._current_uuid()
1178
+ if not u:
1179
+ e.ignore(); return
1180
+
1181
+ if md.hasFormat(MIME_VIEWSTATE):
1182
+ try:
1183
+ st = json.loads(bytes(md.data(MIME_VIEWSTATE)).decode("utf-8"))
1184
+ doc_ptr = int(st.get("doc_ptr", 0))
1185
+ if doc_ptr:
1186
+ self._add_doc_ptrs_to_uuid(u, [doc_ptr])
1187
+ except Exception:
1188
+ pass
1189
+ e.acceptProposedAction()
1190
+ return
1191
+
1192
+ if md.hasUrls():
1193
+ paths = []
1194
+ for url in md.urls():
1195
+ p = url.toLocalFile()
1196
+ if not p:
1197
+ continue
1198
+ if os.path.isdir(p):
1199
+ for r, d, files in os.walk(p):
1200
+ for f in files:
1201
+ if f.lower().endswith(tuple(x.lower() for x in self._file_exts())):
1202
+ paths.append(os.path.join(r, f))
1203
+ else:
1204
+ if p.lower().endswith(tuple(x.lower() for x in self._file_exts())):
1205
+ paths.append(p)
1206
+ if paths:
1207
+ self._add_files_to_uuid(u, paths)
1208
+ e.acceptProposedAction()
1209
+ return
1210
+
1211
+ if md.hasFormat(MIME_CMD):
1212
+ try:
1213
+ payload = _unpack_cmd_safely(bytes(md.data(MIME_CMD)))
1214
+ if payload is None:
1215
+ raise ValueError("Unsupported shortcut payload format")
1216
+ self._apply_payload_to_bundle(payload, target_uuid=u)
1217
+ e.acceptProposedAction()
1218
+ return
1219
+ except Exception as ex:
1220
+ _QMB.warning(self, "Apply to Bundle", f"Could not parse/execute shortcut:\n{ex}")
1221
+ e.ignore()
1222
+
1223
+ # ---------- applying shortcuts to all views in a bundle ----------
1224
+ def _apply_payload_to_bundle(self, payload: dict, target_uuid: Optional[str] = None):
1225
+ mw = _find_main_window(self)
1226
+ if mw is None or not hasattr(mw, "_handle_command_drop"):
1227
+ _QMB.information(self, "Apply", "Main window not available.")
1228
+ return
1229
+
1230
+ payload = _unwrap_cmd_payload(payload)
1231
+ cmd_val = (payload or {}).get("command_id")
1232
+ cmd = cmd_val if isinstance(cmd_val, str) else None
1233
+ if not cmd:
1234
+ _QMB.information(self, "Apply", "Invalid shortcut payload.")
1235
+ return
1236
+ if cmd == "bundle":
1237
+ return # ignore nested bundles
1238
+
1239
+ # --- gather targets ---
1240
+ if target_uuid:
1241
+ b = self._get_bundle(target_uuid)
1242
+ ptrs = [] if not b else list(b.get("doc_ptrs", []))
1243
+ file_paths = [] if not b else list(b.get("file_paths", []))
1244
+ else:
1245
+ ptrs = self.current_bundle_doc_ptrs()
1246
+ file_paths = self.current_bundle_file_paths()
1247
+
1248
+ # --- counters / errors ---
1249
+ view_applied = 0
1250
+ file_ok = 0
1251
+ view_errors: list[str] = []
1252
+ file_errors: list[str] = []
1253
+
1254
+ # ---------- Apply to OPEN VIEWS ----------
1255
+ if cmd == "function_bundle":
1256
+ try:
1257
+ steps = json.loads(json.dumps((payload or {}).get("steps") or []))
1258
+ except Exception:
1259
+ steps = list((payload or {}).get("steps") or [])
1260
+ norm_steps = [s for s in steps if isinstance(s, dict) and s.get("command_id")]
1261
+
1262
+ if norm_steps:
1263
+ for ptr in ptrs:
1264
+ _doc, sw = _resolve_doc_and_subwindow(mw, ptr)
1265
+ if sw is None:
1266
+ continue
1267
+ try:
1268
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
1269
+ mw.mdi.setActiveSubWindow(sw)
1270
+ w = getattr(sw, "widget", lambda: None)()
1271
+ if w:
1272
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1273
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1274
+
1275
+ for st in norm_steps:
1276
+ mw._handle_command_drop(st, target_sw=sw)
1277
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1278
+ view_applied += 1
1279
+ except Exception as e:
1280
+ view_errors.append(str(e))
1281
+ # else: no steps → we’ll still try files below
1282
+ else:
1283
+ for ptr in ptrs:
1284
+ _doc, sw = _resolve_doc_and_subwindow(mw, ptr)
1285
+ if sw is None:
1286
+ continue
1287
+ try:
1288
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
1289
+ mw.mdi.setActiveSubWindow(sw)
1290
+ w = getattr(sw, "widget", lambda: None)()
1291
+ if w:
1292
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1293
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1294
+
1295
+ mw._handle_command_drop(payload, target_sw=sw)
1296
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1297
+ view_applied += 1
1298
+ except Exception as e:
1299
+ view_errors.append(str(e))
1300
+
1301
+ # start total with views
1302
+ total_applied = view_applied
1303
+
1304
+ # ---------- Apply to FILE PATHS ----------
1305
+ if file_paths:
1306
+ for p in file_paths:
1307
+ try:
1308
+ self._apply_payload_to_single_file(payload, p, overwrite=True, out_dir=None)
1309
+ file_ok += 1
1310
+ except Exception as e:
1311
+ tb = traceback.format_exc(limit=6)
1312
+ file_errors.append(f"{os.path.basename(p)}: {e.__class__.__name__}: {e}\n{tb}")
1313
+
1314
+ total_applied += file_ok
1315
+
1316
+ # ---------- Final summary ----------
1317
+ if total_applied == 0 and not (view_errors or file_errors):
1318
+ _QMB.information(self, "Apply", "No valid targets in the bundle.")
1319
+ return
1320
+
1321
+ # If there were any errors, show a detailed mixed summary
1322
+ if view_errors or file_errors:
1323
+ msg = []
1324
+ if view_applied:
1325
+ msg.append(f"Applied to {view_applied} open view(s).")
1326
+ if file_ok:
1327
+ msg.append(f"Applied to {file_ok} file(s).")
1328
+ if view_errors:
1329
+ msg.append("View errors:\n " + "\n ".join(view_errors))
1330
+ if file_errors:
1331
+ msg.append("File errors:\n " + "\n ".join(file_errors))
1332
+ _QMB.warning(self, "Apply", "\n\n".join(msg))
1333
+ return
1334
+
1335
+ _QMB.information(self, "Apply", f"Finished. Applied to {total_applied} target(s).")
1336
+
1337
+
1338
+
1339
+
1340
+ def closeEvent(self, e: QCloseEvent):
1341
+ # keep chips alive; nothing to do
1342
+ super().closeEvent(e)
1343
+
1344
+ def _path_format_from_ext(self, path: str) -> str:
1345
+ ext = os.path.splitext(path)[1].lower().lstrip(".")
1346
+ if ext in ("jpeg",): ext = "jpg"
1347
+ return ext or "fits"
1348
+
1349
+ def _resolve_file_target(self, src_path: str, overwrite: bool, out_dir: str | None) -> str:
1350
+ return (src_path if overwrite or not out_dir
1351
+ else os.path.join(out_dir, os.path.basename(src_path)))
1352
+
1353
+ def _apply_payload_to_single_file(self, payload: dict, path: str,
1354
+ overwrite: bool = True, out_dir: Optional[str] = None) -> bool:
1355
+ """
1356
+ Headless batch that avoids DocManager completely:
1357
+ - load with legacy I/O
1358
+ - wrap in a transient ImageDocument (not added to DocManager)
1359
+ - apply shortcuts via the same dispatcher using a FakeSubWindow
1360
+ - save with legacy I/O
1361
+ """
1362
+ mw = _find_main_window(self)
1363
+ if mw is None:
1364
+ raise RuntimeError("Main window not available")
1365
+
1366
+ # 1) load from disk (no signals, no UI)
1367
+ img, header, bit_depth, is_mono = load_image(path)
1368
+ if img is None:
1369
+ raise RuntimeError(f"Could not load: {path}")
1370
+
1371
+ meta = {
1372
+ "file_path": path,
1373
+ "original_header": header,
1374
+ "bit_depth": bit_depth,
1375
+ "is_mono": is_mono,
1376
+ "original_format": self._path_format_from_ext(path),
1377
+ }
1378
+ # transient doc (NOT registered anywhere)
1379
+ doc = ImageDocument(img, meta)
1380
+
1381
+ # 2) apply
1382
+ pl = _unwrap_cmd_payload(payload) or {}
1383
+ cid = pl.get("command_id")
1384
+ if not isinstance(cid, str):
1385
+ raise RuntimeError("Invalid shortcut payload")
1386
+
1387
+ if cid == "function_bundle":
1388
+ steps = [s for s in (pl.get("steps") or []) if isinstance(s, dict) and s.get("command_id")]
1389
+ if not steps:
1390
+ raise RuntimeError("Function Bundle has no usable steps")
1391
+ for st in steps:
1392
+ _apply_one_shortcut_to_doc(mw, doc, st)
1393
+ elif cid != "bundle": # ignore nested bundles
1394
+ _apply_one_shortcut_to_doc(mw, doc, pl)
1395
+
1396
+ # 3) save back (still no UI)
1397
+ target_path = self._resolve_file_target(path, overwrite, out_dir)
1398
+ ext = os.path.splitext(target_path)[1].lower().lstrip(".")
1399
+ # use legacy writer directly; mirror DocManager’s parameter mapping
1400
+ save_image(
1401
+ img_array=doc.image,
1402
+ filename=target_path,
1403
+ original_format=ext,
1404
+ bit_depth=doc.metadata.get("bit_depth", "32-bit floating point"),
1405
+ original_header=doc.metadata.get("original_header"),
1406
+ is_mono=doc.metadata.get("is_mono", getattr(doc.image, "ndim", 2) == 2),
1407
+ image_meta=doc.metadata.get("image_meta"),
1408
+ file_meta=doc.metadata.get("file_meta"),
1409
+ )
1410
+
1411
+ return True
1412
+
1413
+
1414
+ def _apply_payload_to_single_file_via_ui(self, payload: dict, path: str,
1415
+ overwrite: bool = True, out_dir: Optional[str] = None) -> bool:
1416
+ """
1417
+ Your previous UI-based routine, but using docman.open_path(path) (no file picker).
1418
+ """
1419
+ mw = _find_main_window(self)
1420
+ if mw is None:
1421
+ raise RuntimeError("Main window not available")
1422
+
1423
+ before = set(getattr(mw.mdi, "subWindowList", lambda: [])())
1424
+ opened_sw = None
1425
+ opened_doc = None
1426
+ try:
1427
+ if hasattr(mw, "docman") and hasattr(mw.docman, "open_path"):
1428
+ opened_doc = mw.docman.open_path(path) # no dialogs, emits documentAdded
1429
+ elif hasattr(mw, "_open_image"):
1430
+ mw._open_image(path)
1431
+ else:
1432
+ raise RuntimeError("No file-open method found on main window")
1433
+
1434
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 150)
1435
+
1436
+ if opened_doc is not None and hasattr(mw, "_find_doc_by_id"):
1437
+ _doc, sw = mw._find_doc_by_id(id(opened_doc))
1438
+ opened_sw = sw
1439
+
1440
+ if opened_sw is None:
1441
+ bn = os.path.basename(path)
1442
+ for sw in getattr(mw.mdi, "subWindowList", lambda: [])():
1443
+ if bn in sw.windowTitle():
1444
+ opened_sw = sw
1445
+ break
1446
+ except Exception as e:
1447
+ raise RuntimeError(f"Open failed: {e}")
1448
+
1449
+ if opened_sw is None:
1450
+ raise RuntimeError("Could not resolve newly opened view")
1451
+
1452
+ try:
1453
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not opened_sw:
1454
+ mw.mdi.setActiveSubWindow(opened_sw)
1455
+ w = getattr(opened_sw, "widget", lambda: None)()
1456
+ if w:
1457
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1458
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1459
+ except Exception:
1460
+ pass
1461
+
1462
+ def _apply_one(st):
1463
+ mw._handle_command_drop(st, target_sw=opened_sw)
1464
+
1465
+ pl = _unwrap_cmd_payload(payload) or {}
1466
+ if pl.get("command_id") == "function_bundle":
1467
+ steps = pl.get("steps") or []
1468
+ steps = [s for s in steps if isinstance(s, dict) and s.get("command_id")]
1469
+ if not steps:
1470
+ raise RuntimeError("Function Bundle has no usable steps")
1471
+ for st in steps:
1472
+ _apply_one(st)
1473
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1474
+ elif pl.get("command_id") == "bundle":
1475
+ pass
1476
+ else:
1477
+ _apply_one(pl)
1478
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1479
+
1480
+ # save back
1481
+ target_path = self._resolve_file_target(path, overwrite, out_dir)
1482
+ saved = False
1483
+ try:
1484
+ vw = getattr(opened_sw, "widget", lambda: None)()
1485
+ doc = getattr(vw, "document", None) if vw else None
1486
+ if doc and hasattr(doc, "save_to_path"):
1487
+ doc.save_to_path(target_path); saved = True
1488
+ elif doc and hasattr(doc, "save"):
1489
+ try:
1490
+ doc.save(target_path); saved = True
1491
+ except Exception:
1492
+ if hasattr(doc, "set_filename"):
1493
+ doc.set_filename(target_path); doc.save(); saved = True
1494
+ if not saved and hasattr(mw, "_save_active_document_as"):
1495
+ mw._save_active_document_as(target_path); saved = True
1496
+ if not saved and hasattr(mw, "_save_document_as") and doc:
1497
+ mw._save_document_as(doc, target_path); saved = True
1498
+ if not saved and hasattr(mw, "_save_document") and doc:
1499
+ mw._save_document(doc); saved = True
1500
+ if not saved:
1501
+ raise RuntimeError("No save method available")
1502
+ finally:
1503
+ try:
1504
+ opened_sw.close()
1505
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1506
+ except Exception:
1507
+ pass
1508
+
1509
+ return True
1510
+
1511
+
1512
+
1513
+ # ----------------------------- singleton open helpers -----------------------------
1514
+ _dialog_singleton: ViewBundleDialog | None = None
1515
+
1516
+ def show_view_bundles(parent: QWidget | None,
1517
+ focus_name: str | None = None,
1518
+ *,
1519
+ auto_spawn_only: bool = False):
1520
+ """
1521
+ Open (or focus) the View Bundles dialog. Optionally set focus to a bundle name.
1522
+
1523
+ If auto_spawn_only=True, ensure the dialog + chips exist,
1524
+ but do NOT show the dialog (for startup chip restore).
1525
+ """
1526
+ global _dialog_singleton
1527
+ if _dialog_singleton is None:
1528
+ _dialog_singleton = ViewBundleDialog(parent)
1529
+ # ensure singleton cleared on destroy
1530
+ def _clear():
1531
+ global _dialog_singleton
1532
+ _dialog_singleton = None
1533
+ _dialog_singleton.destroyed.connect(_clear)
1534
+
1535
+ if focus_name:
1536
+ # try to select the bundle by name
1537
+ for i in range(_dialog_singleton.list.count()):
1538
+ if _dialog_singleton.list.item(i).text().strip() == focus_name.strip():
1539
+ _dialog_singleton.list.setCurrentRow(i)
1540
+ break
1541
+
1542
+ if not auto_spawn_only:
1543
+ _dialog_singleton.show()
1544
+ _dialog_singleton.raise_()
1545
+ _dialog_singleton.activateWindow()
1546
+ return _dialog_singleton
1547
+
1548
+ def restore_view_bundle_chips(parent: QWidget | None):
1549
+ """
1550
+ Called at app startup: create the ViewBundleDialog singleton,
1551
+ restore any saved chips onto the ShortcutCanvas, but keep the
1552
+ dialog itself hidden.
1553
+ """
1554
+ try:
1555
+ show_view_bundles(parent, auto_spawn_only=True)
1556
+ except Exception:
1557
+ # fail silently; nothing critical here
1558
+ pass