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,1555 @@
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.setModal(False)
382
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
383
+ self._boxes: list[QCheckBox] = []
384
+
385
+ v = QVBoxLayout(self)
386
+ v.addWidget(QLabel("Choose views to add:"))
387
+ v.setSpacing(6)
388
+
389
+ # NEW: "Select all" checkbox
390
+ self._select_all = QCheckBox("Select all open views")
391
+ self._select_all.toggled.connect(self._on_select_all_toggled)
392
+ v.addWidget(self._select_all)
393
+
394
+ box = QVBoxLayout()
395
+ cont = QWidget(); cont.setLayout(box)
396
+ cont.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.MinimumExpanding)
397
+
398
+ for title, ptr in choices:
399
+ cb = QCheckBox(f"{title}")
400
+ cb.setProperty("doc_ptr", int(ptr))
401
+ box.addWidget(cb)
402
+ self._boxes.append(cb)
403
+
404
+ box.addStretch(1)
405
+ frame = QFrame(); frame.setLayout(box)
406
+ v.addWidget(frame, 1)
407
+
408
+ buttons = QDialogButtonBox(
409
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
410
+ )
411
+ buttons.accepted.connect(self.accept)
412
+ buttons.rejected.connect(self.reject)
413
+ v.addWidget(buttons)
414
+
415
+ # NEW: handler for the "Select all" checkbox
416
+ def _on_select_all_toggled(self, checked: bool):
417
+ for cb in self._boxes:
418
+ cb.setChecked(checked)
419
+
420
+ def selected_ptrs(self) -> list[int]:
421
+ """Return list of doc_ptrs for checked boxes."""
422
+ return [int(cb.property("doc_ptr")) for cb in self._boxes if cb.isChecked()]
423
+
424
+
425
+
426
+ class _HeadlessView:
427
+ def __init__(self, doc, mw):
428
+ self.document = doc
429
+ self._mw = mw
430
+
431
+ def apply_command(self, command_id: str, preset: dict | None = None):
432
+ # Best-effort fallback if someone calls this directly.
433
+ preset = preset or {}
434
+ apply_to_view = getattr(self._mw, "apply_command_to_view", None)
435
+ if callable(apply_to_view):
436
+ return apply_to_view(self, command_id, preset)
437
+ # If nothing else exists, just no-op (don’t raise a user-facing error).
438
+ return None
439
+
440
+ class _FakeSubWindow:
441
+ """Headless stand-in that gives _handle_command_drop a .widget() with .document."""
442
+ def __init__(self, view):
443
+ self._view = view
444
+ def widget(self):
445
+ return self._view
446
+ def windowTitle(self):
447
+ # Try to mirror what a real subwindow title would show
448
+ try:
449
+ doc = getattr(self._view, "document", None)
450
+ if doc:
451
+ name = getattr(doc, "display_name", None)
452
+ if callable(name):
453
+ return name()
454
+ # common fallback attribute(s)
455
+ return getattr(doc, "name", None) or getattr(doc, "filename", None) or "view"
456
+ except Exception:
457
+ pass
458
+ return "view"
459
+
460
+
461
+ def _apply_one_shortcut_to_doc(mw, doc, payload: dict):
462
+ if not isinstance(payload, dict):
463
+ raise RuntimeError("Invalid shortcut payload")
464
+
465
+ cid = payload.get("command_id")
466
+ if isinstance(cid, dict):
467
+ payload = cid
468
+ cid = payload.get("command_id")
469
+ if not isinstance(cid, str) or not cid:
470
+ raise RuntimeError("Invalid command id")
471
+ if cid == "bundle":
472
+ return # ignore nested bundles
473
+
474
+ view = _HeadlessView(doc, mw)
475
+
476
+ # 1) Primary: same as canvas → ShortcutManager path
477
+ handle = getattr(mw, "_handle_command_drop", None)
478
+ if callable(handle):
479
+ # Pass a fake subwindow whose widget() returns our headless view
480
+ fake_sw = _FakeSubWindow(view)
481
+ handle(payload, target_sw=fake_sw)
482
+ return
483
+
484
+ # 2) Secondary: explicit apply-to-view hook
485
+ apply_to_view = getattr(mw, "apply_command_to_view", None)
486
+ if callable(apply_to_view):
487
+ apply_to_view(view, cid, payload.get("preset") or {})
488
+ return
489
+
490
+ # 3) Last-resort: let the shim try a no-op-safe apply_command
491
+ view.apply_command(cid, payload.get("preset") or {})
492
+
493
+
494
+
495
+ # ----------------------------- ViewBundleDialog -----------------------------
496
+ class ViewBundleDialog(QDialog):
497
+ """
498
+ Pure 'bundle of views' manager.
499
+ • Create many bundles (each with a persistent UUID)
500
+ • Drag a view (from ⧉ tab) → add to bundle
501
+ • Add from list of open views
502
+ • Drop a shortcut (MIME_CMD) onto the bundle/panel/chip → apply to all views in THAT bundle
503
+ • Compress → spawns a small Chip on the ShortcutCanvas that keeps accepting DnD
504
+ • Multiple chips at once (one per bundle)
505
+ """
506
+ SETTINGS_KEY = "viewbundles/v3" # bumped for uuid
507
+ CHIP_KEY = "viewbundles/chips_v1" # ← new: chip layout
508
+
509
+ def __init__(self, parent: QWidget | None = None):
510
+ super().__init__(parent)
511
+ _pin_on_top_mac(self)
512
+ self.setWindowTitle("View Bundles")
513
+ self.setModal(False)
514
+ self.resize(900, 540)
515
+ self.setAcceptDrops(True)
516
+
517
+ self._settings = QSettings()
518
+ self._bundles = self._load_all() # [{"uuid":str, "name":str, "doc_ptrs":[int,...]}]
519
+ if not self._bundles:
520
+ self._bundles = [{"uuid": self._new_uuid(), "name": "Bundle 1", "doc_ptrs": []}]
521
+
522
+ # UI
523
+ self.list = QListWidget()
524
+ self.list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
525
+ # rename UX
526
+ self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
527
+ self.list.customContextMenuRequested.connect(self._bundles_context_menu)
528
+ self.list.itemDoubleClicked.connect(lambda _it: self._rename_bundle())
529
+ QShortcut(QKeySequence("F2"), self.list, activated=self._rename_bundle)
530
+
531
+
532
+ self.docs = QListWidget()
533
+ self.docs.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
534
+ # Context menu + double-click niceties on the bundle's treebox/list
535
+ self.docs.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
536
+ self.docs.customContextMenuRequested.connect(self._docs_context_menu)
537
+ self.docs.itemDoubleClicked.connect(self._docs_item_activated)
538
+ self.btn_new = QPushButton("New Bundle")
539
+ self.btn_dup = QPushButton("Duplicate")
540
+ self.btn_del = QPushButton("Delete")
541
+ self.btn_clear = QPushButton("Clear Views")
542
+ self.btn_remove_sel = QPushButton("Remove Selected")
543
+ self.btn_add_from_open = QPushButton("Add from Open…")
544
+ self.btn_add_files = QPushButton("Add Files…")
545
+ self.btn_add_dir = QPushButton("Add Directory (Recursive)…")
546
+ self.btn_compress = QPushButton("Compress to Chip")
547
+ self.drop_hint = QLabel("Drop views here to add • Drop shortcuts here to apply to THIS bundle")
548
+ self.drop_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
549
+ self.drop_hint.setStyleSheet("color:#aaa; padding:6px; border:1px dashed #666; border-radius:6px;")
550
+
551
+ left = QVBoxLayout()
552
+ left.addWidget(QLabel("Bundles"))
553
+ left.addWidget(self.list, 1)
554
+ row = QHBoxLayout()
555
+ row.addWidget(self.btn_new); row.addWidget(self.btn_dup); row.addWidget(self.btn_del)
556
+ left.addLayout(row)
557
+
558
+ right = QVBoxLayout()
559
+ right.addWidget(QLabel("Views in Selected Bundle"))
560
+ right.addWidget(self.docs, 1)
561
+
562
+ rrow = QHBoxLayout()
563
+ rrow.addWidget(self.btn_add_from_open)
564
+ rrow.addStretch(1)
565
+ rrow.addWidget(self.btn_remove_sel)
566
+ rrow.addWidget(self.btn_clear)
567
+ right.addLayout(rrow)
568
+ rrow2 = QHBoxLayout()
569
+ rrow2.addWidget(self.btn_add_files)
570
+ rrow2.addWidget(self.btn_add_dir)
571
+ right.addLayout(rrow2)
572
+ right.addWidget(self.drop_hint)
573
+ right.addWidget(self.btn_compress)
574
+
575
+ split = QSplitter()
576
+ wl = QWidget(); wl.setLayout(left)
577
+ wr = QWidget(); wr.setLayout(right)
578
+ split.addWidget(wl); split.addWidget(wr)
579
+ split.setStretchFactor(0, 0)
580
+ split.setStretchFactor(1, 1)
581
+
582
+ root = QHBoxLayout(self)
583
+ root.addWidget(split)
584
+
585
+ # wiring
586
+ self.btn_new.clicked.connect(self._new_bundle)
587
+ self.btn_dup.clicked.connect(self._dup_bundle)
588
+ self.btn_del.clicked.connect(self._del_bundle)
589
+ self.btn_clear.clicked.connect(self._clear_bundle)
590
+ self.btn_remove_sel.clicked.connect(self._remove_selected)
591
+ self.btn_add_from_open.clicked.connect(self._add_from_open_picker)
592
+ self.btn_compress.clicked.connect(self._compress_to_chip)
593
+ self.list.currentRowChanged.connect(lambda _i: self._refresh_docs_list())
594
+ self.btn_add_files.clicked.connect(self._add_files_into_bundle)
595
+ self.btn_add_dir.clicked.connect(self._add_directory_into_bundle)
596
+ # populate
597
+ self._refresh_bundle_list()
598
+ if self.list.count():
599
+ self.list.setCurrentRow(0)
600
+
601
+ # chips by uuid
602
+ self._chips: dict[str, BundleChip] = {} # uuid -> chip widget
603
+
604
+ try:
605
+ self._restore_chips_from_settings()
606
+ except Exception:
607
+ pass
608
+
609
+ def _save_chip_layout(self):
610
+ """
611
+ Persist current bundle chips and their positions so they reappear
612
+ on the ShortcutCanvas next time SASpro is opened.
613
+ """
614
+ try:
615
+ data = []
616
+ for uuid, chip in list(self._chips.items()):
617
+ if chip is None or chip.parent() is None:
618
+ continue
619
+ pos = chip.pos()
620
+ data.append({
621
+ "uuid": str(uuid),
622
+ "x": int(pos.x()),
623
+ "y": int(pos.y()),
624
+ })
625
+ self._settings.setValue(self.CHIP_KEY, json.dumps(data, ensure_ascii=False))
626
+ self._settings.sync()
627
+ except Exception:
628
+ pass
629
+
630
+ def _restore_chips_from_settings(self):
631
+ """
632
+ Recreate chips on the ShortcutCanvas from saved layout.
633
+ Called on dialog init.
634
+ """
635
+ mw = _find_main_window(self)
636
+ if not mw:
637
+ return
638
+
639
+ raw = self._settings.value(self.CHIP_KEY, "[]", type=str)
640
+ try:
641
+ data = json.loads(raw)
642
+ except Exception:
643
+ data = []
644
+
645
+ if not isinstance(data, list):
646
+ return
647
+
648
+ for entry in data:
649
+ try:
650
+ u = str(entry.get("uuid", "")).strip()
651
+ except Exception:
652
+ continue
653
+ if not u:
654
+ continue
655
+
656
+ # must still exist as a bundle
657
+ b = self._get_bundle(u)
658
+ if not b:
659
+ continue
660
+
661
+ name = b.get("name", "Bundle")
662
+ chip = spawn_bundle_chip_on_canvas(mw, self, u, name)
663
+ if chip is None:
664
+ continue
665
+
666
+ x = entry.get("x")
667
+ y = entry.get("y")
668
+ if isinstance(x, int) and isinstance(y, int):
669
+ chip.move(x, y)
670
+
671
+ self._chips[u] = chip
672
+
673
+ def _remove_chip_widget(self, chip: BundleChip):
674
+ """
675
+ Remove a chip from the canvas and our uuid→chip registry,
676
+ without deleting the underlying bundle.
677
+ """
678
+ # drop from the mapping
679
+ for u, ch in list(self._chips.items()):
680
+ if ch is chip:
681
+ self._chips.pop(u, None)
682
+ break
683
+
684
+ try:
685
+ chip.setParent(None)
686
+ chip.deleteLater()
687
+ except Exception:
688
+ pass
689
+
690
+ self._save_chip_layout()
691
+
692
+
693
+ # ---------- persistence ----------
694
+ @staticmethod
695
+ def _new_uuid() -> str:
696
+ return str(uuid.uuid4())
697
+
698
+ def _ensure_uuid(self, b: dict):
699
+ if "uuid" not in b or not b["uuid"]:
700
+ b["uuid"] = self._new_uuid()
701
+
702
+ def _load_all(self):
703
+ raw = self._settings.value(self.SETTINGS_KEY, "[]", type=str)
704
+ try:
705
+ data = json.loads(raw)
706
+ if isinstance(data, list):
707
+ out = []
708
+ for b in data:
709
+ if not isinstance(b, dict):
710
+ continue
711
+ nm = (b.get("name") or "Bundle").strip()
712
+ ptrs = [int(x) for x in (b.get("doc_ptrs") or []) if isinstance(x, (int, str))]
713
+ fps = [str(p) for p in (b.get("file_paths") or []) if isinstance(p, (str,))]
714
+ u = b.get("uuid") or self._new_uuid()
715
+ out.append({"uuid": u, "name": nm, "doc_ptrs": ptrs, "file_paths": fps})
716
+ return out
717
+ except Exception:
718
+ pass
719
+ return []
720
+
721
+ def _save_all(self):
722
+ try:
723
+ # ensure keys exist
724
+ for b in self._bundles:
725
+ b.setdefault("doc_ptrs", [])
726
+ b.setdefault("file_paths", [])
727
+ self._settings.setValue(self.SETTINGS_KEY, json.dumps(self._bundles, ensure_ascii=False))
728
+ except Exception:
729
+ pass
730
+
731
+ def _file_exts(self):
732
+ return (".fits", ".fit", ".fts", ".fits.gz", ".fit.gz", ".fz", ".xisf", ".tif", ".tiff", ".png", ".jpg", ".jpeg")
733
+
734
+ def _add_files_into_bundle(self):
735
+ u = self._current_uuid()
736
+ if not u:
737
+ return
738
+ last_dir = QSettings().value("last_opened_folder", "", type=str)
739
+ files, _ = QFileDialog.getOpenFileNames(
740
+ self, "Select Files for Bundle", last_dir,
741
+ "Images (*.fits *.fit *.fts *.fits.gz *.fit.gz *.fz *.xisf *.tif *.tiff *.png *.jpg *.jpeg)"
742
+ )
743
+ if not files:
744
+ return
745
+ QSettings().setValue("last_opened_folder", os.path.dirname(files[0]))
746
+ # Dedup in bundle
747
+ self._add_files_to_uuid(u, files)
748
+
749
+ def _add_directory_into_bundle(self):
750
+ u = self._current_uuid()
751
+ if not u:
752
+ return
753
+ last_dir = QSettings().value("last_opened_folder", "", type=str)
754
+ directory = QFileDialog.getExistingDirectory(self, "Select Directory for Bundle", last_dir)
755
+ if not directory:
756
+ return
757
+ QSettings().setValue("last_opened_folder", directory)
758
+ exts = tuple(x.lower() for x in self._file_exts())
759
+ found = []
760
+ for root, dirs, files in os.walk(directory):
761
+ for f in files:
762
+ if f.lower().endswith(exts):
763
+ found.append(os.path.join(root, f))
764
+ if not found:
765
+ _QMB.information(self, "Add Directory", "No supported images found recursively.")
766
+ return
767
+ self._add_files_to_uuid(u, found)
768
+
769
+
770
+ # ---------- bundle lookups / edits ----------
771
+ def _current_index(self) -> int:
772
+ i = self.list.currentRow()
773
+ if i < 0 or i >= len(self._bundles): return -1
774
+ return i
775
+
776
+ def _current_bundle(self) -> Optional[dict]:
777
+ i = self._current_index()
778
+ return None if i < 0 else self._bundles[i]
779
+
780
+ def _current_uuid(self) -> Optional[str]:
781
+ b = self._current_bundle()
782
+ return None if not b else b.get("uuid")
783
+
784
+ def _get_bundle(self, bundle_uuid: str) -> Optional[dict]:
785
+ for b in self._bundles:
786
+ if b.get("uuid") == bundle_uuid:
787
+ # normalize keys
788
+ b.setdefault("doc_ptrs", [])
789
+ b.setdefault("file_paths", [])
790
+ return b
791
+ return None
792
+
793
+ def _rename_current_in_list(self, new_name: str):
794
+ i = self._current_index()
795
+ if i < 0: return
796
+ self._bundles[i]["name"] = (new_name or "Bundle").strip()
797
+ self._save_all()
798
+ self._refresh_bundle_list()
799
+ self.list.setCurrentRow(i)
800
+ # sync chip title if exists
801
+ u = self._bundles[i]["uuid"]
802
+ if u in self._chips:
803
+ self._chips[u].sync_from_panel()
804
+
805
+ def current_bundle_doc_ptrs(self) -> list[int]:
806
+ b = self._current_bundle()
807
+ return [] if not b else list(b.get("doc_ptrs", []))
808
+
809
+ def _set_bundle_ptrs_by_uuid(self, bundle_uuid: str, ptrs: Iterable[int]):
810
+ b = self._get_bundle(bundle_uuid)
811
+ if not b:
812
+ return
813
+ uniq = []
814
+ seen = set()
815
+ for p in ptrs:
816
+ p = int(p)
817
+ if p not in seen:
818
+ seen.add(p); uniq.append(p)
819
+ b["doc_ptrs"] = uniq
820
+ self._save_all()
821
+ # update chip title/count if needed
822
+ if bundle_uuid in self._chips:
823
+ self._chips[bundle_uuid].sync_from_panel()
824
+ # refresh docs if this bundle is selected
825
+ self._refresh_docs_list_if_current_uuid(bundle_uuid)
826
+
827
+ def _add_doc_ptrs_to_uuid(self, bundle_uuid: str, ptrs: Iterable[int]):
828
+ b = self._get_bundle(bundle_uuid)
829
+ if not b:
830
+ return
831
+ cur = list(b.get("doc_ptrs", []))
832
+ merged = cur + [int(p) for p in ptrs]
833
+ self._set_bundle_ptrs_by_uuid(bundle_uuid, merged)
834
+
835
+ def _set_current_bundle_ptrs(self, ptrs: Iterable[int]):
836
+ u = self._current_uuid()
837
+ if not u: return
838
+ self._set_bundle_ptrs_by_uuid(u, ptrs)
839
+
840
+ def _set_bundle_files_by_uuid(self, bundle_uuid: str, paths: Iterable[str]):
841
+ b = self._get_bundle(bundle_uuid)
842
+ if not b:
843
+ return
844
+ uniq = []
845
+ seen = set()
846
+ for p in paths:
847
+ p = str(p)
848
+ if p not in seen:
849
+ seen.add(p); uniq.append(p)
850
+ b["file_paths"] = uniq
851
+ self._save_all()
852
+ if bundle_uuid in self._chips:
853
+ self._chips[bundle_uuid].sync_from_panel()
854
+ self._refresh_docs_list_if_current_uuid(bundle_uuid)
855
+
856
+ def _add_files_to_uuid(self, bundle_uuid: str, paths: Iterable[str]):
857
+ b = self._get_bundle(bundle_uuid)
858
+ if not b:
859
+ return
860
+ cur = list(b.get("file_paths", []))
861
+ merged = cur + [str(p) for p in paths]
862
+ self._set_bundle_files_by_uuid(bundle_uuid, merged)
863
+
864
+ def current_bundle_file_paths(self) -> list[str]:
865
+ b = self._current_bundle()
866
+ if not b: return []
867
+ return list(b.get("file_paths", []))
868
+
869
+ # ---------- UI refresh ----------
870
+ def _refresh_bundle_list(self):
871
+ self.list.clear()
872
+ for b in self._bundles:
873
+ it = QListWidgetItem(b.get("name", "Bundle"))
874
+ self.list.addItem(it)
875
+ # keep selection reasonable
876
+ if self.list.count() and self.list.currentRow() < 0:
877
+ self.list.setCurrentRow(0)
878
+
879
+ # ---------- rename helpers ----------
880
+ def _rename_bundle(self):
881
+ i = self._current_index()
882
+ if i < 0:
883
+ return
884
+ cur = self._bundles[i]
885
+ new_name, ok = QInputDialog.getText(self, "Rename Bundle",
886
+ "New name:", text=cur.get("name","Bundle"))
887
+ if not ok:
888
+ return
889
+ self._rename_current_in_list(new_name)
890
+
891
+ def _bundles_context_menu(self, pos):
892
+ if self.list.count() == 0:
893
+ return
894
+ # focus the item under cursor (so rename/dup/delete applies to it)
895
+ it = self.list.itemAt(pos)
896
+ if it:
897
+ self.list.setCurrentItem(it)
898
+
899
+ m = QMenu(self)
900
+ act_ren = m.addAction("Rename…")
901
+ act_dup = m.addAction("Duplicate")
902
+ act_del = m.addAction("Delete")
903
+ chosen = m.exec(self.list.mapToGlobal(pos))
904
+ if chosen is act_ren:
905
+ self._rename_bundle()
906
+ elif chosen is act_dup:
907
+ self._dup_bundle()
908
+ elif chosen is act_del:
909
+ self._del_bundle()
910
+
911
+ def _refresh_docs_list_if_current_uuid(self, bundle_uuid: str):
912
+ if bundle_uuid and bundle_uuid == self._current_uuid():
913
+ self._refresh_docs_list()
914
+
915
+ def _refresh_docs_list(self):
916
+ self.docs.clear()
917
+ mw = _find_main_window(self)
918
+ # --- views ---
919
+ for p in self.current_bundle_doc_ptrs():
920
+ title = f"(unresolved) [{p}]"
921
+ if mw is not None:
922
+ d, sw = _resolve_doc_and_subwindow(mw, p)
923
+ if d is not None:
924
+ title = sw.windowTitle() if sw else (getattr(d, "display_name", lambda: "Untitled")())
925
+ it = QListWidgetItem(f"[view] {title}")
926
+ it.setData(Qt.ItemDataRole.UserRole, int(p))
927
+ it.setData(Qt.ItemDataRole.UserRole + 1, "view")
928
+ self.docs.addItem(it)
929
+ # --- files ---
930
+ for path in self.current_bundle_file_paths():
931
+ it = QListWidgetItem(f"[file] {path}")
932
+ it.setData(Qt.ItemDataRole.UserRole, path)
933
+ it.setData(Qt.ItemDataRole.UserRole + 1, "file")
934
+ self.docs.addItem(it)
935
+
936
+ # ---------- list niceties: context menu + double-click ----------
937
+ def _docs_item_kind_and_value(self, it):
938
+ """Return ('view'|'file', value) from a QListWidgetItem."""
939
+ if not it:
940
+ return None, None
941
+ kind = it.data(Qt.ItemDataRole.UserRole + 1)
942
+ val = it.data(Qt.ItemDataRole.UserRole)
943
+ return kind, val
944
+
945
+ def _docs_item_activated(self, it):
946
+ """Double-click action: open file, or focus view."""
947
+ kind, val = self._docs_item_kind_and_value(it)
948
+ if kind == "file":
949
+ self._open_file_in_new_view(str(val))
950
+ elif kind == "view":
951
+ self._focus_view_ptr(int(val))
952
+
953
+ def _docs_context_menu(self, pos):
954
+ if self.docs.count() == 0:
955
+ return
956
+ # Focus the item under the cursor so actions apply sensibly
957
+ it = self.docs.itemAt(pos)
958
+ if it:
959
+ it.setSelected(True)
960
+
961
+ # Gather selection breakdown
962
+ sel = [self.docs.item(i) for i in range(self.docs.count()) if self.docs.item(i).isSelected()]
963
+ file_items = [s for s in sel if self._docs_item_kind_and_value(s)[0] == "file"]
964
+ view_items = [s for s in sel if self._docs_item_kind_and_value(s)[0] == "view"]
965
+ if not file_items and not view_items:
966
+ return
967
+
968
+ m = QMenu(self)
969
+ act_open_files = act_focus_views = None
970
+ if file_items:
971
+ lab = "Open in New View" if len(file_items) == 1 else f"Open {len(file_items)} Files in New Views"
972
+ act_open_files = m.addAction(lab)
973
+ if view_items:
974
+ labv = "Focus View" if len(view_items) == 1 else f"Focus {len(view_items)} Views"
975
+ act_focus_views = m.addAction(labv)
976
+
977
+ chosen = m.exec(self.docs.mapToGlobal(pos))
978
+ if chosen is act_open_files:
979
+ for itf in file_items:
980
+ _, path = self._docs_item_kind_and_value(itf)
981
+ self._open_file_in_new_view(str(path))
982
+ elif chosen is act_focus_views:
983
+ for itv in view_items:
984
+ _, ptr = self._docs_item_kind_and_value(itv)
985
+ self._focus_view_ptr(int(ptr))
986
+
987
+ def _focus_view_ptr(self, doc_ptr: int):
988
+ mw = _find_main_window(self)
989
+ if mw is None:
990
+ return
991
+ doc, sw = _resolve_doc_and_subwindow(mw, doc_ptr)
992
+ if sw is None:
993
+ return
994
+ try:
995
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
996
+ mw.mdi.setActiveSubWindow(sw)
997
+ w = getattr(sw, "widget", lambda: None)()
998
+ if w:
999
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1000
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1001
+ except Exception:
1002
+ pass
1003
+
1004
+ def _open_file_in_new_view(self, path: str):
1005
+ """Open a bundle-listed file into a brand-new view (no save/overwrite)."""
1006
+ mw = _find_main_window(self)
1007
+ if mw is None:
1008
+ _QMB.information(self, "Open", "Main window not available.")
1009
+ return
1010
+ try:
1011
+ sw = None
1012
+ opened_doc = None
1013
+ # Prefer docman API if present
1014
+ if hasattr(mw, "docman") and hasattr(mw.docman, "open_path"):
1015
+ opened_doc = mw.docman.open_path(path)
1016
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 120)
1017
+ if opened_doc is not None and hasattr(mw, "_find_doc_by_id"):
1018
+ _doc, sw = mw._find_doc_by_id(id(opened_doc))
1019
+ # Fallback to legacy open hook
1020
+ if sw is None:
1021
+ if hasattr(mw, "_open_image"):
1022
+ mw._open_image(path)
1023
+ else:
1024
+ raise RuntimeError("No file-open method found on main window")
1025
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 120)
1026
+ # best-effort: find by title tail match
1027
+ bn = os.path.basename(path)
1028
+ for cand in getattr(mw.mdi, "subWindowList", lambda: [])():
1029
+ if bn in cand.windowTitle():
1030
+ sw = cand
1031
+ break
1032
+ # Focus the new subwindow
1033
+ if sw is not None:
1034
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
1035
+ mw.mdi.setActiveSubWindow(sw)
1036
+ w = getattr(sw, "widget", lambda: None)()
1037
+ if w:
1038
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1039
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1040
+ except Exception as e:
1041
+ _QMB.warning(self, "Open", f"Could not open:\n{path}\n\n{e}")
1042
+
1043
+ # ---------- left controls ----------
1044
+ def _new_bundle(self):
1045
+ b = {"uuid": self._new_uuid(), "name": f"Bundle {len(self._bundles)+1}", "doc_ptrs": []}
1046
+ self._bundles.append(b)
1047
+ self._save_all(); self._refresh_bundle_list()
1048
+ self.list.setCurrentRow(self.list.count() - 1)
1049
+
1050
+ def _dup_bundle(self):
1051
+ i = self._current_index()
1052
+ if i < 0: return
1053
+ b = self._bundles[i]
1054
+ cp = {
1055
+ "uuid": self._new_uuid(),
1056
+ "name": f"{b.get('name','Bundle')} (copy)",
1057
+ "doc_ptrs": list(b.get("doc_ptrs", []))
1058
+ }
1059
+ self._bundles.insert(i + 1, cp)
1060
+ self._save_all(); self._refresh_bundle_list()
1061
+ self.list.setCurrentRow(i + 1)
1062
+
1063
+ def _del_bundle(self):
1064
+ i = self._current_index()
1065
+ if i < 0: return
1066
+ u = self._bundles[i].get("uuid")
1067
+ # remove chip for this bundle, if any
1068
+ ch = self._chips.pop(u, None)
1069
+ if ch:
1070
+ try:
1071
+ ch.setParent(None)
1072
+ ch.deleteLater()
1073
+ except Exception:
1074
+ pass
1075
+ del self._bundles[i]
1076
+ self._save_all()
1077
+ self._refresh_bundle_list()
1078
+ if self.list.count():
1079
+ self.list.setCurrentRow(min(i, self.list.count() - 1))
1080
+
1081
+ # update chip layout persistence
1082
+ try:
1083
+ self._save_chip_layout()
1084
+ except Exception:
1085
+ pass
1086
+
1087
+
1088
+ # ---------- right controls ----------
1089
+ def _clear_bundle(self):
1090
+ self._set_current_bundle_ptrs([])
1091
+ u = self._current_uuid()
1092
+ if u: self._set_bundle_files_by_uuid(u, [])
1093
+
1094
+ def _remove_selected(self):
1095
+ view_ptrs, file_paths = [], []
1096
+ for i in range(self.docs.count()):
1097
+ it = self.docs.item(i)
1098
+ if not it.isSelected():
1099
+ continue
1100
+ kind = it.data(Qt.ItemDataRole.UserRole + 1)
1101
+ if kind == "view":
1102
+ view_ptrs.append(int(it.data(Qt.ItemDataRole.UserRole)))
1103
+ elif kind == "file":
1104
+ file_paths.append(str(it.data(Qt.ItemDataRole.UserRole)))
1105
+
1106
+ if view_ptrs:
1107
+ remain = [p for p in self.current_bundle_doc_ptrs() if p not in set(view_ptrs)]
1108
+ self._set_current_bundle_ptrs(remain)
1109
+ if file_paths:
1110
+ remain = [p for p in self.current_bundle_file_paths() if p not in set(file_paths)]
1111
+ u = self._current_uuid()
1112
+ if u: self._set_bundle_files_by_uuid(u, remain)
1113
+
1114
+ def _add_from_open_picker(self):
1115
+ mw = _find_main_window(self)
1116
+ if mw is None:
1117
+ _QMB.information(self, "Add from Open", "Main window not available.")
1118
+ return
1119
+ choices: list[tuple[str, int]] = []
1120
+ for sw in mw.mdi.subWindowList():
1121
+ vw = sw.widget()
1122
+ d = getattr(vw, "document", None)
1123
+ if d is not None:
1124
+ choices.append((sw.windowTitle(), int(id(d))))
1125
+ if not choices:
1126
+ _QMB.information(self, "Add from Open", "No open views.")
1127
+ return
1128
+ dlg = SelectViewsDialog(self, choices)
1129
+ if dlg.exec() == QDialog.DialogCode.Accepted:
1130
+ u = self._current_uuid()
1131
+ if not u: return
1132
+ self._add_doc_ptrs_to_uuid(u, dlg.selected_ptrs())
1133
+
1134
+ def _compress_to_chip(self):
1135
+ b = self._current_bundle()
1136
+ if not b: return
1137
+ u = b["uuid"]; name = b.get("name", "Bundle")
1138
+
1139
+ mw = _find_main_window(self)
1140
+ if not mw:
1141
+ _QMB.information(self, "Compress", "Main window not available.")
1142
+ return
1143
+
1144
+ # If a chip for this bundle already exists, just show/raise it
1145
+ chip = self._chips.get(u)
1146
+ if chip is None or chip.parent() is None:
1147
+ chip = spawn_bundle_chip_on_canvas(mw, self, u, name)
1148
+ if chip is None:
1149
+ _QMB.information(self, "Compress", "Shortcut canvas not available.")
1150
+ return
1151
+ self._chips[u] = chip
1152
+ else:
1153
+ chip.sync_from_panel()
1154
+ chip.show()
1155
+ chip.raise_()
1156
+
1157
+ # persist chip presence/position
1158
+ try:
1159
+ self._save_chip_layout()
1160
+ except Exception:
1161
+ pass
1162
+
1163
+
1164
+ # ---------- DnD into the PANEL (applies to CURRENT bundle only) ----------
1165
+ def dragEnterEvent(self, e):
1166
+ md = e.mimeData()
1167
+ if md.hasFormat(MIME_VIEWSTATE) or md.hasFormat(MIME_CMD) or md.hasUrls():
1168
+ e.acceptProposedAction()
1169
+ else:
1170
+ e.ignore()
1171
+
1172
+ def dropEvent(self, e):
1173
+ md = e.mimeData()
1174
+ u = self._current_uuid()
1175
+ if not u:
1176
+ e.ignore(); return
1177
+
1178
+ if md.hasFormat(MIME_VIEWSTATE):
1179
+ try:
1180
+ st = json.loads(bytes(md.data(MIME_VIEWSTATE)).decode("utf-8"))
1181
+ doc_ptr = int(st.get("doc_ptr", 0))
1182
+ if doc_ptr:
1183
+ self._add_doc_ptrs_to_uuid(u, [doc_ptr])
1184
+ except Exception:
1185
+ pass
1186
+ e.acceptProposedAction()
1187
+ return
1188
+
1189
+ if md.hasUrls():
1190
+ paths = []
1191
+ for url in md.urls():
1192
+ p = url.toLocalFile()
1193
+ if not p:
1194
+ continue
1195
+ if os.path.isdir(p):
1196
+ for r, d, files in os.walk(p):
1197
+ for f in files:
1198
+ if f.lower().endswith(tuple(x.lower() for x in self._file_exts())):
1199
+ paths.append(os.path.join(r, f))
1200
+ else:
1201
+ if p.lower().endswith(tuple(x.lower() for x in self._file_exts())):
1202
+ paths.append(p)
1203
+ if paths:
1204
+ self._add_files_to_uuid(u, paths)
1205
+ e.acceptProposedAction()
1206
+ return
1207
+
1208
+ if md.hasFormat(MIME_CMD):
1209
+ try:
1210
+ payload = _unpack_cmd_safely(bytes(md.data(MIME_CMD)))
1211
+ if payload is None:
1212
+ raise ValueError("Unsupported shortcut payload format")
1213
+ self._apply_payload_to_bundle(payload, target_uuid=u)
1214
+ e.acceptProposedAction()
1215
+ return
1216
+ except Exception as ex:
1217
+ _QMB.warning(self, "Apply to Bundle", f"Could not parse/execute shortcut:\n{ex}")
1218
+ e.ignore()
1219
+
1220
+ # ---------- applying shortcuts to all views in a bundle ----------
1221
+ def _apply_payload_to_bundle(self, payload: dict, target_uuid: Optional[str] = None):
1222
+ mw = _find_main_window(self)
1223
+ if mw is None or not hasattr(mw, "_handle_command_drop"):
1224
+ _QMB.information(self, "Apply", "Main window not available.")
1225
+ return
1226
+
1227
+ payload = _unwrap_cmd_payload(payload)
1228
+ cmd_val = (payload or {}).get("command_id")
1229
+ cmd = cmd_val if isinstance(cmd_val, str) else None
1230
+ if not cmd:
1231
+ _QMB.information(self, "Apply", "Invalid shortcut payload.")
1232
+ return
1233
+ if cmd == "bundle":
1234
+ return # ignore nested bundles
1235
+
1236
+ # --- gather targets ---
1237
+ if target_uuid:
1238
+ b = self._get_bundle(target_uuid)
1239
+ ptrs = [] if not b else list(b.get("doc_ptrs", []))
1240
+ file_paths = [] if not b else list(b.get("file_paths", []))
1241
+ else:
1242
+ ptrs = self.current_bundle_doc_ptrs()
1243
+ file_paths = self.current_bundle_file_paths()
1244
+
1245
+ # --- counters / errors ---
1246
+ view_applied = 0
1247
+ file_ok = 0
1248
+ view_errors: list[str] = []
1249
+ file_errors: list[str] = []
1250
+
1251
+ # ---------- Apply to OPEN VIEWS ----------
1252
+ if cmd == "function_bundle":
1253
+ try:
1254
+ steps = json.loads(json.dumps((payload or {}).get("steps") or []))
1255
+ except Exception:
1256
+ steps = list((payload or {}).get("steps") or [])
1257
+ norm_steps = [s for s in steps if isinstance(s, dict) and s.get("command_id")]
1258
+
1259
+ if norm_steps:
1260
+ for ptr in ptrs:
1261
+ _doc, sw = _resolve_doc_and_subwindow(mw, ptr)
1262
+ if sw is None:
1263
+ continue
1264
+ try:
1265
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
1266
+ mw.mdi.setActiveSubWindow(sw)
1267
+ w = getattr(sw, "widget", lambda: None)()
1268
+ if w:
1269
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1270
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1271
+
1272
+ for st in norm_steps:
1273
+ mw._handle_command_drop(st, target_sw=sw)
1274
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1275
+ view_applied += 1
1276
+ except Exception as e:
1277
+ view_errors.append(str(e))
1278
+ # else: no steps → we’ll still try files below
1279
+ else:
1280
+ for ptr in ptrs:
1281
+ _doc, sw = _resolve_doc_and_subwindow(mw, ptr)
1282
+ if sw is None:
1283
+ continue
1284
+ try:
1285
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not sw:
1286
+ mw.mdi.setActiveSubWindow(sw)
1287
+ w = getattr(sw, "widget", lambda: None)()
1288
+ if w:
1289
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1290
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1291
+
1292
+ mw._handle_command_drop(payload, target_sw=sw)
1293
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1294
+ view_applied += 1
1295
+ except Exception as e:
1296
+ view_errors.append(str(e))
1297
+
1298
+ # start total with views
1299
+ total_applied = view_applied
1300
+
1301
+ # ---------- Apply to FILE PATHS ----------
1302
+ if file_paths:
1303
+ for p in file_paths:
1304
+ try:
1305
+ self._apply_payload_to_single_file(payload, p, overwrite=True, out_dir=None)
1306
+ file_ok += 1
1307
+ except Exception as e:
1308
+ tb = traceback.format_exc(limit=6)
1309
+ file_errors.append(f"{os.path.basename(p)}: {e.__class__.__name__}: {e}\n{tb}")
1310
+
1311
+ total_applied += file_ok
1312
+
1313
+ # ---------- Final summary ----------
1314
+ if total_applied == 0 and not (view_errors or file_errors):
1315
+ _QMB.information(self, "Apply", "No valid targets in the bundle.")
1316
+ return
1317
+
1318
+ # If there were any errors, show a detailed mixed summary
1319
+ if view_errors or file_errors:
1320
+ msg = []
1321
+ if view_applied:
1322
+ msg.append(f"Applied to {view_applied} open view(s).")
1323
+ if file_ok:
1324
+ msg.append(f"Applied to {file_ok} file(s).")
1325
+ if view_errors:
1326
+ msg.append("View errors:\n " + "\n ".join(view_errors))
1327
+ if file_errors:
1328
+ msg.append("File errors:\n " + "\n ".join(file_errors))
1329
+ _QMB.warning(self, "Apply", "\n\n".join(msg))
1330
+ return
1331
+
1332
+ _QMB.information(self, "Apply", f"Finished. Applied to {total_applied} target(s).")
1333
+
1334
+
1335
+
1336
+
1337
+ def closeEvent(self, e: QCloseEvent):
1338
+ # keep chips alive; nothing to do
1339
+ super().closeEvent(e)
1340
+
1341
+ def _path_format_from_ext(self, path: str) -> str:
1342
+ ext = os.path.splitext(path)[1].lower().lstrip(".")
1343
+ if ext in ("jpeg",): ext = "jpg"
1344
+ return ext or "fits"
1345
+
1346
+ def _resolve_file_target(self, src_path: str, overwrite: bool, out_dir: str | None) -> str:
1347
+ return (src_path if overwrite or not out_dir
1348
+ else os.path.join(out_dir, os.path.basename(src_path)))
1349
+
1350
+ def _apply_payload_to_single_file(self, payload: dict, path: str,
1351
+ overwrite: bool = True, out_dir: Optional[str] = None) -> bool:
1352
+ """
1353
+ Headless batch that avoids DocManager completely:
1354
+ - load with legacy I/O
1355
+ - wrap in a transient ImageDocument (not added to DocManager)
1356
+ - apply shortcuts via the same dispatcher using a FakeSubWindow
1357
+ - save with legacy I/O
1358
+ """
1359
+ mw = _find_main_window(self)
1360
+ if mw is None:
1361
+ raise RuntimeError("Main window not available")
1362
+
1363
+ # 1) load from disk (no signals, no UI)
1364
+ img, header, bit_depth, is_mono = load_image(path)
1365
+ if img is None:
1366
+ raise RuntimeError(f"Could not load: {path}")
1367
+
1368
+ meta = {
1369
+ "file_path": path,
1370
+ "original_header": header,
1371
+ "bit_depth": bit_depth,
1372
+ "is_mono": is_mono,
1373
+ "original_format": self._path_format_from_ext(path),
1374
+ }
1375
+ # transient doc (NOT registered anywhere)
1376
+ doc = ImageDocument(img, meta)
1377
+
1378
+ # 2) apply
1379
+ pl = _unwrap_cmd_payload(payload) or {}
1380
+ cid = pl.get("command_id")
1381
+ if not isinstance(cid, str):
1382
+ raise RuntimeError("Invalid shortcut payload")
1383
+
1384
+ if cid == "function_bundle":
1385
+ steps = [s for s in (pl.get("steps") or []) if isinstance(s, dict) and s.get("command_id")]
1386
+ if not steps:
1387
+ raise RuntimeError("Function Bundle has no usable steps")
1388
+ for st in steps:
1389
+ _apply_one_shortcut_to_doc(mw, doc, st)
1390
+ elif cid != "bundle": # ignore nested bundles
1391
+ _apply_one_shortcut_to_doc(mw, doc, pl)
1392
+
1393
+ # 3) save back (still no UI)
1394
+ target_path = self._resolve_file_target(path, overwrite, out_dir)
1395
+ ext = os.path.splitext(target_path)[1].lower().lstrip(".")
1396
+ # use legacy writer directly; mirror DocManager’s parameter mapping
1397
+ save_image(
1398
+ img_array=doc.image,
1399
+ filename=target_path,
1400
+ original_format=ext,
1401
+ bit_depth=doc.metadata.get("bit_depth", "32-bit floating point"),
1402
+ original_header=doc.metadata.get("original_header"),
1403
+ is_mono=doc.metadata.get("is_mono", getattr(doc.image, "ndim", 2) == 2),
1404
+ image_meta=doc.metadata.get("image_meta"),
1405
+ file_meta=doc.metadata.get("file_meta"),
1406
+ )
1407
+
1408
+ return True
1409
+
1410
+
1411
+ def _apply_payload_to_single_file_via_ui(self, payload: dict, path: str,
1412
+ overwrite: bool = True, out_dir: Optional[str] = None) -> bool:
1413
+ """
1414
+ Your previous UI-based routine, but using docman.open_path(path) (no file picker).
1415
+ """
1416
+ mw = _find_main_window(self)
1417
+ if mw is None:
1418
+ raise RuntimeError("Main window not available")
1419
+
1420
+ before = set(getattr(mw.mdi, "subWindowList", lambda: [])())
1421
+ opened_sw = None
1422
+ opened_doc = None
1423
+ try:
1424
+ if hasattr(mw, "docman") and hasattr(mw.docman, "open_path"):
1425
+ opened_doc = mw.docman.open_path(path) # no dialogs, emits documentAdded
1426
+ elif hasattr(mw, "_open_image"):
1427
+ mw._open_image(path)
1428
+ else:
1429
+ raise RuntimeError("No file-open method found on main window")
1430
+
1431
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 150)
1432
+
1433
+ if opened_doc is not None and hasattr(mw, "_find_doc_by_id"):
1434
+ _doc, sw = mw._find_doc_by_id(id(opened_doc))
1435
+ opened_sw = sw
1436
+
1437
+ if opened_sw is None:
1438
+ bn = os.path.basename(path)
1439
+ for sw in getattr(mw.mdi, "subWindowList", lambda: [])():
1440
+ if bn in sw.windowTitle():
1441
+ opened_sw = sw
1442
+ break
1443
+ except Exception as e:
1444
+ raise RuntimeError(f"Open failed: {e}")
1445
+
1446
+ if opened_sw is None:
1447
+ raise RuntimeError("Could not resolve newly opened view")
1448
+
1449
+ try:
1450
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow() is not opened_sw:
1451
+ mw.mdi.setActiveSubWindow(opened_sw)
1452
+ w = getattr(opened_sw, "widget", lambda: None)()
1453
+ if w:
1454
+ w.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
1455
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
1456
+ except Exception:
1457
+ pass
1458
+
1459
+ def _apply_one(st):
1460
+ mw._handle_command_drop(st, target_sw=opened_sw)
1461
+
1462
+ pl = _unwrap_cmd_payload(payload) or {}
1463
+ if pl.get("command_id") == "function_bundle":
1464
+ steps = pl.get("steps") or []
1465
+ steps = [s for s in steps if isinstance(s, dict) and s.get("command_id")]
1466
+ if not steps:
1467
+ raise RuntimeError("Function Bundle has no usable steps")
1468
+ for st in steps:
1469
+ _apply_one(st)
1470
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1471
+ elif pl.get("command_id") == "bundle":
1472
+ pass
1473
+ else:
1474
+ _apply_one(pl)
1475
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1476
+
1477
+ # save back
1478
+ target_path = self._resolve_file_target(path, overwrite, out_dir)
1479
+ saved = False
1480
+ try:
1481
+ vw = getattr(opened_sw, "widget", lambda: None)()
1482
+ doc = getattr(vw, "document", None) if vw else None
1483
+ if doc and hasattr(doc, "save_to_path"):
1484
+ doc.save_to_path(target_path); saved = True
1485
+ elif doc and hasattr(doc, "save"):
1486
+ try:
1487
+ doc.save(target_path); saved = True
1488
+ except Exception:
1489
+ if hasattr(doc, "set_filename"):
1490
+ doc.set_filename(target_path); doc.save(); saved = True
1491
+ if not saved and hasattr(mw, "_save_active_document_as"):
1492
+ mw._save_active_document_as(target_path); saved = True
1493
+ if not saved and hasattr(mw, "_save_document_as") and doc:
1494
+ mw._save_document_as(doc, target_path); saved = True
1495
+ if not saved and hasattr(mw, "_save_document") and doc:
1496
+ mw._save_document(doc); saved = True
1497
+ if not saved:
1498
+ raise RuntimeError("No save method available")
1499
+ finally:
1500
+ try:
1501
+ opened_sw.close()
1502
+ QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 0)
1503
+ except Exception:
1504
+ pass
1505
+
1506
+ return True
1507
+
1508
+
1509
+
1510
+ # ----------------------------- singleton open helpers -----------------------------
1511
+ _dialog_singleton: ViewBundleDialog | None = None
1512
+
1513
+ def show_view_bundles(parent: QWidget | None,
1514
+ focus_name: str | None = None,
1515
+ *,
1516
+ auto_spawn_only: bool = False):
1517
+ """
1518
+ Open (or focus) the View Bundles dialog. Optionally set focus to a bundle name.
1519
+
1520
+ If auto_spawn_only=True, ensure the dialog + chips exist,
1521
+ but do NOT show the dialog (for startup chip restore).
1522
+ """
1523
+ global _dialog_singleton
1524
+ if _dialog_singleton is None:
1525
+ _dialog_singleton = ViewBundleDialog(parent)
1526
+ # ensure singleton cleared on destroy
1527
+ def _clear():
1528
+ global _dialog_singleton
1529
+ _dialog_singleton = None
1530
+ _dialog_singleton.destroyed.connect(_clear)
1531
+
1532
+ if focus_name:
1533
+ # try to select the bundle by name
1534
+ for i in range(_dialog_singleton.list.count()):
1535
+ if _dialog_singleton.list.item(i).text().strip() == focus_name.strip():
1536
+ _dialog_singleton.list.setCurrentRow(i)
1537
+ break
1538
+
1539
+ if not auto_spawn_only:
1540
+ _dialog_singleton.show()
1541
+ _dialog_singleton.raise_()
1542
+ _dialog_singleton.activateWindow()
1543
+ return _dialog_singleton
1544
+
1545
+ def restore_view_bundle_chips(parent: QWidget | None):
1546
+ """
1547
+ Called at app startup: create the ViewBundleDialog singleton,
1548
+ restore any saved chips onto the ShortcutCanvas, but keep the
1549
+ dialog itself hidden.
1550
+ """
1551
+ try:
1552
+ show_view_bundles(parent, auto_spawn_only=True)
1553
+ except Exception:
1554
+ # fail silently; nothing critical here
1555
+ pass