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