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