setiastrosuitepro 1.6.5.post3__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.
- 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/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/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 +958 -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 +698 -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 +611 -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 +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 +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 +983 -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 +8792 -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 +390 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
- setiastro/saspro/gui/mixins/update_mixin.py +323 -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 +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 +1086 -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 +407 -0
- setiastro/saspro/multiscale_decomp.py +1747 -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 +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 +446 -0
- setiastro/saspro/resources.py +503 -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 +1611 -0
- setiastro/saspro/sfcc.py +1472 -0
- setiastro/saspro/shortcuts.py +3116 -0
- setiastro/saspro/signature_insert.py +1102 -0
- setiastro/saspro/stacking_suite.py +19066 -0
- setiastro/saspro/star_alignment.py +7380 -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 +3407 -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 +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 +513 -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 +263 -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 +7996 -0
- setiastro/saspro/wims.py +578 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1213 -0
- setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
- setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
- setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
# ops/script_editor.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import io
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from setiastro.saspro.ops.scripts import get_scripts_dir # your existing helper
|
|
9
|
+
|
|
10
|
+
from PyQt6.QtCore import Qt, QRect, QSize, QRegularExpression
|
|
11
|
+
from PyQt6.QtGui import QFont, QAction, QColor, QPainter, QTextCursor, QTextDocument, QSyntaxHighlighter, QTextCharFormat
|
|
12
|
+
from PyQt6.QtWidgets import (
|
|
13
|
+
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QPlainTextEdit, QPushButton,
|
|
14
|
+
QLabel, QMessageBox, QFileDialog, QSplitter, QInputDialog, QDockWidget,
|
|
15
|
+
QLineEdit, QToolButton, QCheckBox, QTextEdit
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# -----------------------------------------------------------------------------
|
|
19
|
+
# Code editor with line numbers (QPlainTextEdit subclass)
|
|
20
|
+
# -----------------------------------------------------------------------------
|
|
21
|
+
class LineNumberArea(QWidget):
|
|
22
|
+
def __init__(self, editor: "CodeEditor"):
|
|
23
|
+
super().__init__(editor)
|
|
24
|
+
self.code_editor = editor
|
|
25
|
+
|
|
26
|
+
def sizeHint(self):
|
|
27
|
+
return QSize(self.code_editor.line_number_area_width(), 0)
|
|
28
|
+
|
|
29
|
+
def paintEvent(self, event):
|
|
30
|
+
# Always paint line numbers with the editor's font
|
|
31
|
+
painter = QPainter(self)
|
|
32
|
+
painter.setFont(self.code_editor.font())
|
|
33
|
+
self.code_editor.line_number_area_paint_event(event)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CodeEditor(QPlainTextEdit):
|
|
38
|
+
INDENT = " " # 4 spaces; change to "\t" if you prefer tabs
|
|
39
|
+
def __init__(self, parent=None):
|
|
40
|
+
super().__init__(parent)
|
|
41
|
+
self._line_number_area = LineNumberArea(self)
|
|
42
|
+
|
|
43
|
+
self._line_number_area.setFont(self.font())
|
|
44
|
+
|
|
45
|
+
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
|
46
|
+
|
|
47
|
+
self.blockCountChanged.connect(self._update_line_number_area_width)
|
|
48
|
+
self.updateRequest.connect(self._update_line_number_area)
|
|
49
|
+
self.cursorPositionChanged.connect(self._highlight_current_line)
|
|
50
|
+
|
|
51
|
+
# --- NEW: indent guide toggle ---
|
|
52
|
+
self.show_indent_guides = True
|
|
53
|
+
|
|
54
|
+
self._update_line_number_area_width(0)
|
|
55
|
+
self._highlight_current_line()
|
|
56
|
+
|
|
57
|
+
def setFont(self, font: QFont) -> None:
|
|
58
|
+
"""Ensure editor and line-number area share the same font."""
|
|
59
|
+
super().setFont(font)
|
|
60
|
+
try:
|
|
61
|
+
if hasattr(self, "_line_number_area") and self._line_number_area is not None:
|
|
62
|
+
self._line_number_area.setFont(font)
|
|
63
|
+
# Recompute gutter width when font changes
|
|
64
|
+
self._update_line_number_area_width(0)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def line_number_area_width(self):
|
|
70
|
+
digits = max(1, len(str(self.blockCount())))
|
|
71
|
+
space = 6 + self.fontMetrics().horizontalAdvance("9") * digits
|
|
72
|
+
return space
|
|
73
|
+
|
|
74
|
+
def _update_line_number_area_width(self, _):
|
|
75
|
+
self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)
|
|
76
|
+
|
|
77
|
+
def _update_line_number_area(self, rect, dy):
|
|
78
|
+
if dy:
|
|
79
|
+
self._line_number_area.scroll(0, dy)
|
|
80
|
+
else:
|
|
81
|
+
self._line_number_area.update(0, rect.y(), self._line_number_area.width(), rect.height())
|
|
82
|
+
if rect.contains(self.viewport().rect()):
|
|
83
|
+
self._update_line_number_area_width(0)
|
|
84
|
+
|
|
85
|
+
def resizeEvent(self, event):
|
|
86
|
+
super().resizeEvent(event)
|
|
87
|
+
cr = self.contentsRect()
|
|
88
|
+
self._line_number_area.setGeometry(
|
|
89
|
+
QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def line_number_area_paint_event(self, event):
|
|
93
|
+
painter = QPainter(self._line_number_area)
|
|
94
|
+
painter.fillRect(event.rect(), QColor(30, 30, 30))
|
|
95
|
+
|
|
96
|
+
block = self.firstVisibleBlock()
|
|
97
|
+
block_number = block.blockNumber()
|
|
98
|
+
top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
|
|
99
|
+
bottom = top + int(self.blockBoundingRect(block).height())
|
|
100
|
+
|
|
101
|
+
while block.isValid() and top <= event.rect().bottom():
|
|
102
|
+
if block.isVisible() and bottom >= event.rect().top():
|
|
103
|
+
number = str(block_number + 1)
|
|
104
|
+
painter.setPen(QColor(140, 140, 140))
|
|
105
|
+
painter.drawText(
|
|
106
|
+
0, top, self._line_number_area.width() - 4,
|
|
107
|
+
self.fontMetrics().height(),
|
|
108
|
+
Qt.AlignmentFlag.AlignRight,
|
|
109
|
+
number
|
|
110
|
+
)
|
|
111
|
+
block = block.next()
|
|
112
|
+
top = bottom
|
|
113
|
+
bottom = top + int(self.blockBoundingRect(block).height())
|
|
114
|
+
block_number += 1
|
|
115
|
+
|
|
116
|
+
def _highlight_current_line(self):
|
|
117
|
+
# Subtle current-line highlight
|
|
118
|
+
extra = []
|
|
119
|
+
if not self.isReadOnly():
|
|
120
|
+
sel = QTextEdit.ExtraSelection()
|
|
121
|
+
sel.format.setBackground(QColor(45, 45, 45))
|
|
122
|
+
sel.format.setProperty(sel.format.Property.FullWidthSelection, True)
|
|
123
|
+
sel.cursor = self.textCursor()
|
|
124
|
+
sel.cursor.clearSelection()
|
|
125
|
+
extra.append(sel)
|
|
126
|
+
self.setExtraSelections(extra)
|
|
127
|
+
|
|
128
|
+
def open_find_bar(self, replace=False):
|
|
129
|
+
self.find_bar.set_replace_mode(replace)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def contextMenuEvent(self, e):
|
|
133
|
+
menu = self.createStandardContextMenu()
|
|
134
|
+
|
|
135
|
+
menu.addSeparator()
|
|
136
|
+
act_find = menu.addAction("Find…")
|
|
137
|
+
act_replace = menu.addAction("Replace…")
|
|
138
|
+
|
|
139
|
+
act_find.triggered.connect(lambda: self.open_find_bar(replace=False))
|
|
140
|
+
act_replace.triggered.connect(lambda: self.open_find_bar(replace=True))
|
|
141
|
+
|
|
142
|
+
menu.exec(e.globalPos())
|
|
143
|
+
|
|
144
|
+
def toggle_find_bar(self, replace: bool = False):
|
|
145
|
+
"""Show/hide the find bar. If showing, optionally switch replace mode."""
|
|
146
|
+
fb = getattr(self, "find_bar", None)
|
|
147
|
+
if fb is None:
|
|
148
|
+
# fallback: just open normal find UI
|
|
149
|
+
return self.open_find_bar(replace=replace)
|
|
150
|
+
|
|
151
|
+
vis = fb.isVisible()
|
|
152
|
+
fb.setVisible(not vis)
|
|
153
|
+
if not vis:
|
|
154
|
+
try:
|
|
155
|
+
fb.set_replace_mode(bool(replace))
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
try:
|
|
159
|
+
fb.focus_find()
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
# -------------------------------
|
|
164
|
+
# Indent / dedent helpers
|
|
165
|
+
# -------------------------------
|
|
166
|
+
def _indent_blocks(self, start_block: int, end_block: int):
|
|
167
|
+
doc = self.document()
|
|
168
|
+
cursor = self.textCursor()
|
|
169
|
+
cursor.beginEditBlock()
|
|
170
|
+
for bn in range(start_block, end_block + 1):
|
|
171
|
+
block = doc.findBlockByNumber(bn)
|
|
172
|
+
if not block.isValid():
|
|
173
|
+
continue
|
|
174
|
+
c = QTextCursor(block)
|
|
175
|
+
c.movePosition(QTextCursor.MoveOperation.StartOfBlock)
|
|
176
|
+
c.insertText(self.INDENT)
|
|
177
|
+
cursor.endEditBlock()
|
|
178
|
+
|
|
179
|
+
def _dedent_blocks(self, start_block: int, end_block: int):
|
|
180
|
+
doc = self.document()
|
|
181
|
+
cursor = self.textCursor()
|
|
182
|
+
cursor.beginEditBlock()
|
|
183
|
+
for bn in range(start_block, end_block + 1):
|
|
184
|
+
block = doc.findBlockByNumber(bn)
|
|
185
|
+
if not block.isValid():
|
|
186
|
+
continue
|
|
187
|
+
text = block.text()
|
|
188
|
+
c = QTextCursor(block)
|
|
189
|
+
c.movePosition(QTextCursor.MoveOperation.StartOfBlock)
|
|
190
|
+
|
|
191
|
+
# Remove indent if present
|
|
192
|
+
if text.startswith(self.INDENT):
|
|
193
|
+
for _ in range(len(self.INDENT)):
|
|
194
|
+
c.deleteChar()
|
|
195
|
+
elif text.startswith("\t"):
|
|
196
|
+
c.deleteChar()
|
|
197
|
+
cursor.endEditBlock()
|
|
198
|
+
|
|
199
|
+
def _selected_block_range(self):
|
|
200
|
+
cur = self.textCursor()
|
|
201
|
+
doc = self.document()
|
|
202
|
+
start = cur.selectionStart()
|
|
203
|
+
end = cur.selectionEnd()
|
|
204
|
+
|
|
205
|
+
start_block = doc.findBlock(start).blockNumber()
|
|
206
|
+
end_block = doc.findBlock(end).blockNumber()
|
|
207
|
+
return start_block, end_block
|
|
208
|
+
|
|
209
|
+
# -------------------------------
|
|
210
|
+
# Key handling for Tab / Shift+Tab
|
|
211
|
+
# -------------------------------
|
|
212
|
+
def keyPressEvent(self, e):
|
|
213
|
+
key = e.key()
|
|
214
|
+
|
|
215
|
+
# Tab: indent selection or insert indent
|
|
216
|
+
if key == Qt.Key.Key_Tab and not (e.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
|
217
|
+
cur = self.textCursor()
|
|
218
|
+
if cur.hasSelection():
|
|
219
|
+
sb, eb = self._selected_block_range()
|
|
220
|
+
self._indent_blocks(sb, eb)
|
|
221
|
+
else:
|
|
222
|
+
cur.insertText(self.INDENT)
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Shift+Tab (Backtab): dedent selection
|
|
226
|
+
if key == Qt.Key.Key_Backtab:
|
|
227
|
+
cur = self.textCursor()
|
|
228
|
+
if cur.hasSelection():
|
|
229
|
+
sb, eb = self._selected_block_range()
|
|
230
|
+
self._dedent_blocks(sb, eb)
|
|
231
|
+
else:
|
|
232
|
+
# single-line dedent
|
|
233
|
+
sb, eb = self._selected_block_range()
|
|
234
|
+
self._dedent_blocks(sb, sb)
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
super().keyPressEvent(e)
|
|
238
|
+
|
|
239
|
+
def insertFromMimeData(self, source):
|
|
240
|
+
"""
|
|
241
|
+
Normalize pasted indentation:
|
|
242
|
+
- Convert tabs to 4 spaces
|
|
243
|
+
- Replace weird NBSP with normal space
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
text = source.text()
|
|
247
|
+
if text:
|
|
248
|
+
text = text.replace("\u00A0", " ") # NBSP → space
|
|
249
|
+
text = text.replace("\t", self.INDENT)
|
|
250
|
+
self.textCursor().insertText(text)
|
|
251
|
+
return
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
254
|
+
super().insertFromMimeData(source)
|
|
255
|
+
|
|
256
|
+
def paintEvent(self, event):
|
|
257
|
+
# let Qt paint text first
|
|
258
|
+
super().paintEvent(event)
|
|
259
|
+
|
|
260
|
+
if not getattr(self, "show_indent_guides", True):
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
painter = QPainter(self.viewport())
|
|
264
|
+
painter.setPen(QColor(100, 85, 115, 120))
|
|
265
|
+
|
|
266
|
+
indent_chars = len(self.INDENT)
|
|
267
|
+
indent_px = self.fontMetrics().horizontalAdvance(" ") * indent_chars
|
|
268
|
+
|
|
269
|
+
block = self.firstVisibleBlock()
|
|
270
|
+
top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
|
|
271
|
+
bottom = top + int(self.blockBoundingRect(block).height())
|
|
272
|
+
|
|
273
|
+
# contentOffset().x() is 0 at no scroll, negative when scrolled right
|
|
274
|
+
x_scroll = int(self.contentOffset().x())
|
|
275
|
+
view_w = self.viewport().width()
|
|
276
|
+
|
|
277
|
+
while block.isValid() and top <= event.rect().bottom():
|
|
278
|
+
if block.isVisible() and bottom >= event.rect().top():
|
|
279
|
+
text = block.text()
|
|
280
|
+
|
|
281
|
+
# count leading WS, treating tabs as INDENT width
|
|
282
|
+
lead = 0
|
|
283
|
+
for ch in text:
|
|
284
|
+
if ch == " ":
|
|
285
|
+
lead += 1
|
|
286
|
+
elif ch == "\t":
|
|
287
|
+
lead += indent_chars
|
|
288
|
+
else:
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
level = lead // indent_chars
|
|
292
|
+
if level > 0:
|
|
293
|
+
for i in range(1, level + 1):
|
|
294
|
+
x = int(i * indent_px + x_scroll)
|
|
295
|
+
if 0 <= x <= view_w:
|
|
296
|
+
painter.drawLine(x, top, x, bottom)
|
|
297
|
+
|
|
298
|
+
block = block.next()
|
|
299
|
+
top = bottom
|
|
300
|
+
bottom = top + int(self.blockBoundingRect(block).height())
|
|
301
|
+
|
|
302
|
+
# -----------------------------------------------------------------------------
|
|
303
|
+
# Find / Replace bar
|
|
304
|
+
# -----------------------------------------------------------------------------
|
|
305
|
+
class FindReplaceBar(QWidget):
|
|
306
|
+
def __init__(self, editor: CodeEditor, parent=None):
|
|
307
|
+
super().__init__(parent)
|
|
308
|
+
self.editor = editor
|
|
309
|
+
self._last_find = ""
|
|
310
|
+
|
|
311
|
+
lay = QHBoxLayout(self)
|
|
312
|
+
lay.setContentsMargins(6, 4, 6, 4)
|
|
313
|
+
lay.setSpacing(6)
|
|
314
|
+
|
|
315
|
+
self.find_edit = QLineEdit()
|
|
316
|
+
self.find_edit.setPlaceholderText("Find…")
|
|
317
|
+
|
|
318
|
+
self.replace_edit = QLineEdit()
|
|
319
|
+
self.replace_edit.setPlaceholderText("Replace with…")
|
|
320
|
+
self.replace_edit.setVisible(False)
|
|
321
|
+
|
|
322
|
+
self.chk_case = QCheckBox("Case")
|
|
323
|
+
self.chk_word = QCheckBox("Word")
|
|
324
|
+
self.chk_wrap = QCheckBox("Wrap")
|
|
325
|
+
self.chk_wrap.setChecked(True)
|
|
326
|
+
|
|
327
|
+
self.btn_prev = QToolButton(); self.btn_prev.setText("Prev")
|
|
328
|
+
self.btn_next = QToolButton(); self.btn_next.setText("Next")
|
|
329
|
+
self.btn_replace = QToolButton(); self.btn_replace.setText("Replace")
|
|
330
|
+
self.btn_replace_all = QToolButton(); self.btn_replace_all.setText("All")
|
|
331
|
+
self.btn_close = QToolButton(); self.btn_close.setText("✕")
|
|
332
|
+
|
|
333
|
+
self.btn_replace.setVisible(False)
|
|
334
|
+
self.btn_replace_all.setVisible(False)
|
|
335
|
+
|
|
336
|
+
lay.addWidget(QLabel("Find:"))
|
|
337
|
+
lay.addWidget(self.find_edit, 2)
|
|
338
|
+
lay.addWidget(QLabel("Replace:"))
|
|
339
|
+
lay.addWidget(self.replace_edit, 2)
|
|
340
|
+
lay.addWidget(self.chk_case)
|
|
341
|
+
lay.addWidget(self.chk_word)
|
|
342
|
+
lay.addWidget(self.chk_wrap)
|
|
343
|
+
lay.addWidget(self.btn_prev)
|
|
344
|
+
lay.addWidget(self.btn_next)
|
|
345
|
+
lay.addWidget(self.btn_replace)
|
|
346
|
+
lay.addWidget(self.btn_replace_all)
|
|
347
|
+
lay.addWidget(self.btn_close)
|
|
348
|
+
|
|
349
|
+
# wiring
|
|
350
|
+
self.find_edit.returnPressed.connect(self.find_next)
|
|
351
|
+
self.btn_next.clicked.connect(self.find_next)
|
|
352
|
+
self.btn_prev.clicked.connect(self.find_prev)
|
|
353
|
+
self.btn_replace.clicked.connect(self.replace_one)
|
|
354
|
+
self.btn_replace_all.clicked.connect(self.replace_all)
|
|
355
|
+
self.btn_close.clicked.connect(self.hide)
|
|
356
|
+
|
|
357
|
+
def show_find(self):
|
|
358
|
+
self.replace_edit.setVisible(False)
|
|
359
|
+
self.btn_replace.setVisible(False)
|
|
360
|
+
self.btn_replace_all.setVisible(False)
|
|
361
|
+
self.show()
|
|
362
|
+
self.find_edit.setFocus()
|
|
363
|
+
self.find_edit.selectAll()
|
|
364
|
+
|
|
365
|
+
def show_replace(self):
|
|
366
|
+
self.replace_edit.setVisible(True)
|
|
367
|
+
self.btn_replace.setVisible(True)
|
|
368
|
+
self.btn_replace_all.setVisible(True)
|
|
369
|
+
self.show()
|
|
370
|
+
self.find_edit.setFocus()
|
|
371
|
+
self.find_edit.selectAll()
|
|
372
|
+
|
|
373
|
+
# ---- internal flags ----
|
|
374
|
+
def _flags(self, backward=False):
|
|
375
|
+
flags = QTextDocument.FindFlag(0)
|
|
376
|
+
if backward:
|
|
377
|
+
flags |= QTextDocument.FindFlag.FindBackward
|
|
378
|
+
if self.chk_case.isChecked():
|
|
379
|
+
flags |= QTextDocument.FindFlag.FindCaseSensitively
|
|
380
|
+
if self.chk_word.isChecked():
|
|
381
|
+
flags |= QTextDocument.FindFlag.FindWholeWords
|
|
382
|
+
return flags
|
|
383
|
+
|
|
384
|
+
def _do_find(self, backward=False):
|
|
385
|
+
text = self.find_edit.text()
|
|
386
|
+
if not text:
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
self._last_find = text
|
|
390
|
+
ok = self.editor.find(text, self._flags(backward=backward))
|
|
391
|
+
|
|
392
|
+
if not ok and self.chk_wrap.isChecked():
|
|
393
|
+
# wrap to start/end
|
|
394
|
+
cursor = self.editor.textCursor()
|
|
395
|
+
cursor.movePosition(
|
|
396
|
+
QTextCursor.MoveOperation.End if backward else QTextCursor.MoveOperation.Start
|
|
397
|
+
)
|
|
398
|
+
self.editor.setTextCursor(cursor)
|
|
399
|
+
ok = self.editor.find(text, self._flags(backward=backward))
|
|
400
|
+
|
|
401
|
+
return ok
|
|
402
|
+
|
|
403
|
+
def find_next(self):
|
|
404
|
+
self._do_find(backward=False)
|
|
405
|
+
|
|
406
|
+
def find_prev(self):
|
|
407
|
+
self._do_find(backward=True)
|
|
408
|
+
|
|
409
|
+
def replace_one(self):
|
|
410
|
+
find_text = self.find_edit.text()
|
|
411
|
+
if not find_text:
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
cursor = self.editor.textCursor()
|
|
415
|
+
if cursor.hasSelection() and cursor.selectedText() == find_text:
|
|
416
|
+
cursor.insertText(self.replace_edit.text())
|
|
417
|
+
self.editor.setTextCursor(cursor)
|
|
418
|
+
|
|
419
|
+
self.find_next()
|
|
420
|
+
|
|
421
|
+
def replace_all(self):
|
|
422
|
+
find_text = self.find_edit.text()
|
|
423
|
+
if not find_text:
|
|
424
|
+
return
|
|
425
|
+
replace_text = self.replace_edit.text()
|
|
426
|
+
|
|
427
|
+
cursor = self.editor.textCursor()
|
|
428
|
+
cursor.beginEditBlock()
|
|
429
|
+
|
|
430
|
+
# start from top
|
|
431
|
+
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
|
432
|
+
self.editor.setTextCursor(cursor)
|
|
433
|
+
|
|
434
|
+
count = 0
|
|
435
|
+
while self.editor.find(find_text, self._flags(backward=False)):
|
|
436
|
+
c = self.editor.textCursor()
|
|
437
|
+
if c.hasSelection():
|
|
438
|
+
c.insertText(replace_text)
|
|
439
|
+
count += 1
|
|
440
|
+
|
|
441
|
+
cursor.endEditBlock()
|
|
442
|
+
|
|
443
|
+
def set_replace_mode(self, replace: bool):
|
|
444
|
+
"""Switch between find-only and find+replace UI."""
|
|
445
|
+
if replace:
|
|
446
|
+
self.show_replace()
|
|
447
|
+
else:
|
|
448
|
+
self.show_find()
|
|
449
|
+
|
|
450
|
+
def focus_find(self):
|
|
451
|
+
"""Put focus in the find box."""
|
|
452
|
+
self.find_edit.setFocus()
|
|
453
|
+
self.find_edit.selectAll()
|
|
454
|
+
|
|
455
|
+
class _StdCapture:
|
|
456
|
+
"""Context manager to capture stdout/stderr into a StringIO."""
|
|
457
|
+
def __init__(self):
|
|
458
|
+
self.buf = io.StringIO()
|
|
459
|
+
self._old_out = None
|
|
460
|
+
self._old_err = None
|
|
461
|
+
|
|
462
|
+
def __enter__(self):
|
|
463
|
+
self._old_out, self._old_err = sys.stdout, sys.stderr
|
|
464
|
+
sys.stdout = sys.stderr = self.buf
|
|
465
|
+
return self
|
|
466
|
+
|
|
467
|
+
def __exit__(self, exc_type, exc, tb):
|
|
468
|
+
sys.stdout, sys.stderr = self._old_out, self._old_err
|
|
469
|
+
|
|
470
|
+
def text(self) -> str:
|
|
471
|
+
return self.buf.getvalue()
|
|
472
|
+
|
|
473
|
+
class PythonHighlighter(QSyntaxHighlighter):
|
|
474
|
+
"""
|
|
475
|
+
Simple Python syntax highlighter for QPlainTextEdit/QTextDocument.
|
|
476
|
+
Dark-theme friendly colors.
|
|
477
|
+
"""
|
|
478
|
+
def __init__(self, document):
|
|
479
|
+
super().__init__(document)
|
|
480
|
+
|
|
481
|
+
def fmt(color, bold=False, italic=False):
|
|
482
|
+
f = QTextCharFormat()
|
|
483
|
+
f.setForeground(QColor(color))
|
|
484
|
+
if bold:
|
|
485
|
+
f.setFontWeight(QFont.Weight.Bold)
|
|
486
|
+
if italic:
|
|
487
|
+
f.setFontItalic(True)
|
|
488
|
+
return f
|
|
489
|
+
|
|
490
|
+
# ---- formats ----
|
|
491
|
+
self.f_keyword = fmt("#C586C0", bold=True)
|
|
492
|
+
self.f_builtin = fmt("#4FC1FF")
|
|
493
|
+
self.f_number = fmt("#B5CEA8")
|
|
494
|
+
self.f_string = fmt("#CE9178")
|
|
495
|
+
self.f_comment = fmt("#6A9955", italic=True)
|
|
496
|
+
self.f_decorator = fmt("#DCDCAA")
|
|
497
|
+
self.f_self = fmt("#9CDCFE")
|
|
498
|
+
self.f_defclass = fmt("#569CD6", bold=True)
|
|
499
|
+
# whitespace warnings
|
|
500
|
+
self.f_trailing_ws = QTextCharFormat()
|
|
501
|
+
self.f_trailing_ws.setUnderlineColor(QColor("#F44747")) # soft red
|
|
502
|
+
self.f_trailing_ws.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
|
|
503
|
+
|
|
504
|
+
self.f_tab_ws = QTextCharFormat()
|
|
505
|
+
self.f_tab_ws.setBackground(QColor(80, 20, 20, 120)) # faint red-ish block
|
|
506
|
+
# ---- keyword lists ----
|
|
507
|
+
keywords = [
|
|
508
|
+
"False","None","True","and","as","assert","async","await","break",
|
|
509
|
+
"class","continue","def","del","elif","else","except","finally","for",
|
|
510
|
+
"from","global","if","import","in","is","lambda","nonlocal","not",
|
|
511
|
+
"or","pass","raise","return","try","while","with","yield","match","case"
|
|
512
|
+
]
|
|
513
|
+
|
|
514
|
+
builtins = [
|
|
515
|
+
"abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes",
|
|
516
|
+
"callable","chr","classmethod","compile","complex","delattr","dict","dir",
|
|
517
|
+
"divmod","enumerate","eval","exec","filter","float","format","frozenset",
|
|
518
|
+
"getattr","globals","hasattr","hash","help","hex","id","input","int",
|
|
519
|
+
"isinstance","issubclass","iter","len","list","locals","map","max",
|
|
520
|
+
"memoryview","min","next","object","oct","open","ord","pow","print",
|
|
521
|
+
"property","range","repr","reversed","round","set","setattr","slice",
|
|
522
|
+
"sorted","staticmethod","str","sum","super","tuple","type","vars","zip"
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
# ---- rules ----
|
|
526
|
+
self.rules = []
|
|
527
|
+
|
|
528
|
+
# keywords
|
|
529
|
+
for kw in keywords:
|
|
530
|
+
self.rules.append((QRegularExpression(rf"\b{kw}\b"), self.f_keyword))
|
|
531
|
+
|
|
532
|
+
# builtins
|
|
533
|
+
for bi in builtins:
|
|
534
|
+
self.rules.append((QRegularExpression(rf"\b{bi}\b"), self.f_builtin))
|
|
535
|
+
|
|
536
|
+
# def / class name highlighting
|
|
537
|
+
self.rules.append((QRegularExpression(r"\bdef\s+([A-Za-z_]\w*)"), self.f_defclass))
|
|
538
|
+
self.rules.append((QRegularExpression(r"\bclass\s+([A-Za-z_]\w*)"), self.f_defclass))
|
|
539
|
+
|
|
540
|
+
# decorators
|
|
541
|
+
self.rules.append((QRegularExpression(r"^\s*@\w+"), self.f_decorator))
|
|
542
|
+
|
|
543
|
+
# numbers (int/float/hex/binary with underscores)
|
|
544
|
+
self.rules.append((QRegularExpression(r"\b0[xX][0-9A-Fa-f_]+\b"), self.f_number))
|
|
545
|
+
self.rules.append((QRegularExpression(r"\b0[bB][01_]+\b"), self.f_number))
|
|
546
|
+
self.rules.append((QRegularExpression(r"\b0[oO][0-7_]+\b"), self.f_number))
|
|
547
|
+
self.rules.append((QRegularExpression(r"\b\d[\d_]*(\.\d[\d_]*)?([eE][+-]?\d[\d_]*)?\b"), self.f_number))
|
|
548
|
+
|
|
549
|
+
# self / cls
|
|
550
|
+
self.rules.append((QRegularExpression(r"\bself\b"), self.f_self))
|
|
551
|
+
self.rules.append((QRegularExpression(r"\bcls\b"), self.f_self))
|
|
552
|
+
self.rules.append((QRegularExpression(r"\bctx\b"), self.f_self))
|
|
553
|
+
|
|
554
|
+
# single-line strings
|
|
555
|
+
self.rules.append((QRegularExpression(r"(?<!\\)'.*?(?<!\\)'"), self.f_string))
|
|
556
|
+
self.rules.append((QRegularExpression(r'(?<!\\)".*?(?<!\\)"'), self.f_string))
|
|
557
|
+
|
|
558
|
+
# comments
|
|
559
|
+
self.rules.append((QRegularExpression(r"#.*$"), self.f_comment))
|
|
560
|
+
|
|
561
|
+
# multiline triple-quoted strings
|
|
562
|
+
self.tri_single = QRegularExpression("'''")
|
|
563
|
+
self.tri_double = QRegularExpression('"""')
|
|
564
|
+
|
|
565
|
+
def highlightBlock(self, text: str):
|
|
566
|
+
# apply normal single-line rules
|
|
567
|
+
for pattern, form in self.rules:
|
|
568
|
+
it = pattern.globalMatch(text)
|
|
569
|
+
while it.hasNext():
|
|
570
|
+
m = it.next()
|
|
571
|
+
start = m.capturedStart()
|
|
572
|
+
length = m.capturedLength()
|
|
573
|
+
self.setFormat(start, length, form)
|
|
574
|
+
|
|
575
|
+
# handle multiline triple strings
|
|
576
|
+
self.setCurrentBlockState(0)
|
|
577
|
+
self._do_multiline(text, self.tri_single, 1, self.f_string)
|
|
578
|
+
self._do_multiline(text, self.tri_double, 2, self.f_string)
|
|
579
|
+
|
|
580
|
+
# -------- NEW: whitespace warnings --------
|
|
581
|
+
|
|
582
|
+
# 1) highlight any literal tabs
|
|
583
|
+
tab_re = QRegularExpression(r"\t+")
|
|
584
|
+
it = tab_re.globalMatch(text)
|
|
585
|
+
while it.hasNext():
|
|
586
|
+
m = it.next()
|
|
587
|
+
self.setFormat(m.capturedStart(), m.capturedLength(), self.f_tab_ws)
|
|
588
|
+
|
|
589
|
+
# 2) highlight trailing whitespace at end of line
|
|
590
|
+
trailing_re = QRegularExpression(r"[ \t]+$")
|
|
591
|
+
m = trailing_re.match(text)
|
|
592
|
+
if m.hasMatch():
|
|
593
|
+
self.setFormat(m.capturedStart(), m.capturedLength(), self.f_trailing_ws)
|
|
594
|
+
|
|
595
|
+
# 3) (optional) warn on NBSP too, just in case
|
|
596
|
+
nbsp_re = QRegularExpression(u"\u00A0+")
|
|
597
|
+
it = nbsp_re.globalMatch(text)
|
|
598
|
+
while it.hasNext():
|
|
599
|
+
m = it.next()
|
|
600
|
+
self.setFormat(m.capturedStart(), m.capturedLength(), self.f_trailing_ws)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _do_multiline(self, text: str, delimiter: QRegularExpression, in_state: int, style: QTextCharFormat):
|
|
604
|
+
delim_len = 3
|
|
605
|
+
prev_in = (self.previousBlockState() == in_state)
|
|
606
|
+
|
|
607
|
+
# If we're continuing from a previous block, content starts at 0.
|
|
608
|
+
if prev_in:
|
|
609
|
+
start = 0
|
|
610
|
+
else:
|
|
611
|
+
m_start = delimiter.match(text)
|
|
612
|
+
start = m_start.capturedStart() if m_start.hasMatch() else -1
|
|
613
|
+
|
|
614
|
+
while start >= 0:
|
|
615
|
+
# If continuing, allow delimiter at column 0 to close.
|
|
616
|
+
search_from = start if prev_in else start + delim_len
|
|
617
|
+
|
|
618
|
+
m_end = delimiter.match(text, search_from)
|
|
619
|
+
end = m_end.capturedStart() if m_end.hasMatch() else -1
|
|
620
|
+
|
|
621
|
+
if end >= 0:
|
|
622
|
+
length = end - start + delim_len
|
|
623
|
+
self.setFormat(start, length, style)
|
|
624
|
+
|
|
625
|
+
# We closed the multiline string in this block.
|
|
626
|
+
self.setCurrentBlockState(0)
|
|
627
|
+
|
|
628
|
+
# Look for another opening delimiter later in the same line.
|
|
629
|
+
m_next = delimiter.match(text, start + length)
|
|
630
|
+
start = m_next.capturedStart() if m_next.hasMatch() else -1
|
|
631
|
+
prev_in = False
|
|
632
|
+
else:
|
|
633
|
+
# No closing delimiter found: highlight to end of line and stay "in string".
|
|
634
|
+
self.setFormat(start, len(text) - start, style)
|
|
635
|
+
self.setCurrentBlockState(in_state)
|
|
636
|
+
break
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
class ScriptEditorDock(QDockWidget):
|
|
641
|
+
def __init__(self, app_window, parent=None):
|
|
642
|
+
super().__init__("Script Editor", parent or app_window)
|
|
643
|
+
self.app = app_window
|
|
644
|
+
self.scripts_dir = get_scripts_dir()
|
|
645
|
+
|
|
646
|
+
self._current_path: Path | None = None
|
|
647
|
+
self._dirty = False
|
|
648
|
+
|
|
649
|
+
root = QWidget(self)
|
|
650
|
+
self.setWidget(root)
|
|
651
|
+
|
|
652
|
+
main = QVBoxLayout(root)
|
|
653
|
+
|
|
654
|
+
# --- top bar (2 rows) ---
|
|
655
|
+
barwrap = QVBoxLayout()
|
|
656
|
+
barwrap.setContentsMargins(0, 0, 0, 0)
|
|
657
|
+
barwrap.setSpacing(4)
|
|
658
|
+
|
|
659
|
+
# Row 1: file label + file ops
|
|
660
|
+
row1 = QHBoxLayout()
|
|
661
|
+
self.lbl_file = QLabel("No script loaded")
|
|
662
|
+
row1.addWidget(self.lbl_file, 0)
|
|
663
|
+
row1.addSpacing(12)
|
|
664
|
+
|
|
665
|
+
self.btn_new = QPushButton("New")
|
|
666
|
+
self.btn_save = QPushButton("Save")
|
|
667
|
+
self.btn_save_as = QPushButton("Save As…")
|
|
668
|
+
self.btn_delete = QPushButton("🗑 Delete") # if you added it already
|
|
669
|
+
|
|
670
|
+
row1.addWidget(self.btn_new)
|
|
671
|
+
row1.addWidget(self.btn_save)
|
|
672
|
+
row1.addWidget(self.btn_save_as)
|
|
673
|
+
row1.addWidget(self.btn_delete)
|
|
674
|
+
row1.addStretch(1)
|
|
675
|
+
|
|
676
|
+
# Row 2: edit + run + help ops
|
|
677
|
+
row2 = QHBoxLayout()
|
|
678
|
+
|
|
679
|
+
self.btn_find = QPushButton("🔍 Find")
|
|
680
|
+
self.btn_replace = QPushButton("🧩 Replace")
|
|
681
|
+
|
|
682
|
+
self.btn_run = QPushButton("🟢▶ Run")
|
|
683
|
+
self.btn_run_base = QPushButton("▶ Run on Base")
|
|
684
|
+
|
|
685
|
+
self.btn_reload = QPushButton("Reload Scripts")
|
|
686
|
+
self.btn_cmd_help = QPushButton("❓ Command Help")
|
|
687
|
+
|
|
688
|
+
row2.addWidget(self.btn_find)
|
|
689
|
+
row2.addWidget(self.btn_replace)
|
|
690
|
+
row2.addSpacing(12)
|
|
691
|
+
row2.addWidget(self.btn_run)
|
|
692
|
+
row2.addWidget(self.btn_run_base)
|
|
693
|
+
row2.addSpacing(12)
|
|
694
|
+
row2.addWidget(self.btn_reload)
|
|
695
|
+
row2.addSpacing(12)
|
|
696
|
+
row2.addWidget(self.btn_cmd_help)
|
|
697
|
+
row2.addStretch(1)
|
|
698
|
+
|
|
699
|
+
barwrap.addLayout(row1)
|
|
700
|
+
barwrap.addLayout(row2)
|
|
701
|
+
main.addLayout(barwrap)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# --- splitter: left list, right editor/output ---
|
|
705
|
+
split = QSplitter(Qt.Orientation.Horizontal)
|
|
706
|
+
|
|
707
|
+
# left script list
|
|
708
|
+
left = QWidget()
|
|
709
|
+
left_lay = QVBoxLayout(left)
|
|
710
|
+
left_lay.setContentsMargins(0,0,0,0)
|
|
711
|
+
left_lay.addWidget(QLabel("Scripts"))
|
|
712
|
+
|
|
713
|
+
self.list_scripts = QListWidget()
|
|
714
|
+
left_lay.addWidget(self.list_scripts, 1)
|
|
715
|
+
|
|
716
|
+
# right: editor + output
|
|
717
|
+
right = QSplitter(Qt.Orientation.Vertical)
|
|
718
|
+
|
|
719
|
+
# editor
|
|
720
|
+
editor_wrap = QWidget()
|
|
721
|
+
editor_lay = QVBoxLayout(editor_wrap)
|
|
722
|
+
editor_lay.setContentsMargins(0, 0, 0, 0)
|
|
723
|
+
editor_lay.setSpacing(0)
|
|
724
|
+
|
|
725
|
+
self.editor = CodeEditor()
|
|
726
|
+
self.highlighter = PythonHighlighter(self.editor.document())
|
|
727
|
+
f = QFont("Consolas")
|
|
728
|
+
f.setStyleHint(QFont.StyleHint.Monospace)
|
|
729
|
+
f.setPointSize(10)
|
|
730
|
+
self.editor.setFont(f)
|
|
731
|
+
self.editor.setTabStopDistance(4 * self.editor.fontMetrics().horizontalAdvance(' '))
|
|
732
|
+
|
|
733
|
+
self.find_bar = FindReplaceBar(self.editor)
|
|
734
|
+
self.find_bar.hide()
|
|
735
|
+
|
|
736
|
+
editor_lay.addWidget(self.find_bar)
|
|
737
|
+
editor_lay.addWidget(self.editor, 1)
|
|
738
|
+
self.editor.find_bar = self.find_bar
|
|
739
|
+
|
|
740
|
+
# Now wire toolbar buttons (editor exists now)
|
|
741
|
+
self.btn_find.clicked.connect(lambda: self.editor.open_find_bar(replace=False))
|
|
742
|
+
self.btn_replace.clicked.connect(lambda: self.editor.open_find_bar(replace=True))
|
|
743
|
+
right.addWidget(editor_wrap)
|
|
744
|
+
|
|
745
|
+
# output
|
|
746
|
+
out_wrap = QWidget()
|
|
747
|
+
out_lay = QVBoxLayout(out_wrap)
|
|
748
|
+
out_lay.setContentsMargins(0,0,0,0)
|
|
749
|
+
out_lay.addWidget(QLabel("Output / Traceback"))
|
|
750
|
+
|
|
751
|
+
self.output = QPlainTextEdit()
|
|
752
|
+
self.output.setReadOnly(True)
|
|
753
|
+
self.output.setFont(f)
|
|
754
|
+
out_lay.addWidget(self.output, 1)
|
|
755
|
+
|
|
756
|
+
row2 = QHBoxLayout()
|
|
757
|
+
self.btn_copy = QPushButton("Copy All")
|
|
758
|
+
self.btn_clear = QPushButton("Clear")
|
|
759
|
+
row2.addStretch(1)
|
|
760
|
+
row2.addWidget(self.btn_copy)
|
|
761
|
+
row2.addWidget(self.btn_clear)
|
|
762
|
+
out_lay.addLayout(row2)
|
|
763
|
+
|
|
764
|
+
right.addWidget(out_wrap)
|
|
765
|
+
|
|
766
|
+
split.addWidget(left)
|
|
767
|
+
split.addWidget(right)
|
|
768
|
+
split.setStretchFactor(1, 1)
|
|
769
|
+
main.addWidget(split, 1)
|
|
770
|
+
|
|
771
|
+
# --- wiring ---
|
|
772
|
+
self.btn_new.clicked.connect(self.new_script)
|
|
773
|
+
self.btn_save.clicked.connect(self.save_script)
|
|
774
|
+
self.btn_save_as.clicked.connect(self.save_script_as)
|
|
775
|
+
self.btn_run.clicked.connect(lambda: self.run_script(on_base=False))
|
|
776
|
+
self.btn_run_base.clicked.connect(lambda: self.run_script(on_base=True))
|
|
777
|
+
self.btn_reload.clicked.connect(self.reload_scripts)
|
|
778
|
+
self.btn_cmd_help.clicked.connect(self.open_command_help)
|
|
779
|
+
self.btn_copy.clicked.connect(self.copy_all_output)
|
|
780
|
+
self.btn_clear.clicked.connect(lambda: self.output.setPlainText(""))
|
|
781
|
+
self.btn_delete.clicked.connect(self.delete_script)
|
|
782
|
+
|
|
783
|
+
self.list_scripts.itemDoubleClicked.connect(
|
|
784
|
+
lambda it: self.open_script(self.scripts_dir / it.text())
|
|
785
|
+
)
|
|
786
|
+
self.editor.textChanged.connect(self._mark_dirty)
|
|
787
|
+
# --- Find / Replace shortcuts ---
|
|
788
|
+
self.act_find = QAction("Find", self)
|
|
789
|
+
self.act_find.setShortcut("Ctrl+F")
|
|
790
|
+
self.act_find.triggered.connect(self.find_bar.show_find)
|
|
791
|
+
self.addAction(self.act_find)
|
|
792
|
+
|
|
793
|
+
self.act_replace = QAction("Replace", self)
|
|
794
|
+
self.act_replace.setShortcut("Ctrl+H")
|
|
795
|
+
self.act_replace.triggered.connect(self.find_bar.show_replace)
|
|
796
|
+
self.addAction(self.act_replace)
|
|
797
|
+
|
|
798
|
+
self.act_find_next = QAction("Find Next", self)
|
|
799
|
+
self.act_find_next.setShortcut("F3")
|
|
800
|
+
self.act_find_next.triggered.connect(self.find_bar.find_next)
|
|
801
|
+
self.addAction(self.act_find_next)
|
|
802
|
+
|
|
803
|
+
self.act_find_prev = QAction("Find Previous", self)
|
|
804
|
+
self.act_find_prev.setShortcut("Shift+F3")
|
|
805
|
+
self.act_find_prev.triggered.connect(self.find_bar.find_prev)
|
|
806
|
+
self.addAction(self.act_find_prev)
|
|
807
|
+
|
|
808
|
+
self.reload_scripts()
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ------------------------------------------------------------------
|
|
812
|
+
# list management
|
|
813
|
+
def reload_scripts(self):
|
|
814
|
+
self.list_scripts.clear()
|
|
815
|
+
for p in sorted(self.scripts_dir.glob("*.py")):
|
|
816
|
+
self.list_scripts.addItem(p.name)
|
|
817
|
+
|
|
818
|
+
if hasattr(self.app, "scriptman"):
|
|
819
|
+
self.app.scriptman.load_registry()
|
|
820
|
+
if hasattr(self.app, "menu_scripts"):
|
|
821
|
+
self.app.scriptman.rebuild_menu(self.app.menu_scripts)
|
|
822
|
+
|
|
823
|
+
self._log(f"Reloaded scripts from {self.scripts_dir}")
|
|
824
|
+
|
|
825
|
+
def open_command_help(self):
|
|
826
|
+
try:
|
|
827
|
+
from setiastro.saspro.ops.command_help_dialog import CommandHelpDialog
|
|
828
|
+
except Exception as e:
|
|
829
|
+
QMessageBox.critical(self, "Command Help", f"Failed to open help dialog:\n{e}")
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
dlg = CommandHelpDialog(parent=self, editor=self.editor)
|
|
833
|
+
dlg.exec()
|
|
834
|
+
|
|
835
|
+
# ------------------------------------------------------------------
|
|
836
|
+
# file operations
|
|
837
|
+
def maybe_save_dirty(self) -> bool:
|
|
838
|
+
if not self._dirty:
|
|
839
|
+
return True
|
|
840
|
+
r = QMessageBox.question(
|
|
841
|
+
self, "Unsaved Changes",
|
|
842
|
+
"This script has unsaved changes. Save now?",
|
|
843
|
+
QMessageBox.StandardButton.Yes |
|
|
844
|
+
QMessageBox.StandardButton.No |
|
|
845
|
+
QMessageBox.StandardButton.Cancel
|
|
846
|
+
)
|
|
847
|
+
if r == QMessageBox.StandardButton.Cancel:
|
|
848
|
+
return False
|
|
849
|
+
if r == QMessageBox.StandardButton.Yes:
|
|
850
|
+
return self.save_script()
|
|
851
|
+
return True
|
|
852
|
+
|
|
853
|
+
def new_script(self):
|
|
854
|
+
if not self.maybe_save_dirty():
|
|
855
|
+
return
|
|
856
|
+
name, ok = QInputDialog.getText(
|
|
857
|
+
self, "New Script", "Script name (no extension):"
|
|
858
|
+
)
|
|
859
|
+
if not ok or not name.strip():
|
|
860
|
+
return
|
|
861
|
+
path = self.scripts_dir / f"{name.strip()}.py"
|
|
862
|
+
if path.exists():
|
|
863
|
+
QMessageBox.warning(self, "Exists", "A script with that name already exists.")
|
|
864
|
+
return
|
|
865
|
+
|
|
866
|
+
template = (
|
|
867
|
+
"# SASpro user script\n"
|
|
868
|
+
"SCRIPT_NAME = \"New Script\"\n"
|
|
869
|
+
"SCRIPT_GROUP = \"User\"\n\n"
|
|
870
|
+
"def run(ctx):\n"
|
|
871
|
+
" ctx.log(\"Hello from New Script\")\n"
|
|
872
|
+
" # img = ctx.get_image()\n"
|
|
873
|
+
" # ctx.set_image(img, step_name=\"Script\")\n"
|
|
874
|
+
)
|
|
875
|
+
path.write_text(template, encoding="utf-8")
|
|
876
|
+
self.open_script(path)
|
|
877
|
+
self.reload_scripts()
|
|
878
|
+
|
|
879
|
+
def open_script(self, path: Path):
|
|
880
|
+
if not self.maybe_save_dirty():
|
|
881
|
+
return
|
|
882
|
+
try:
|
|
883
|
+
txt = path.read_text(encoding="utf-8")
|
|
884
|
+
except Exception as e:
|
|
885
|
+
QMessageBox.critical(self, "Open failed", str(e))
|
|
886
|
+
return
|
|
887
|
+
self.editor.setPlainText(txt)
|
|
888
|
+
self._current_path = path
|
|
889
|
+
self._dirty = False
|
|
890
|
+
self._update_title()
|
|
891
|
+
|
|
892
|
+
def save_script(self) -> bool:
|
|
893
|
+
if self._current_path is None:
|
|
894
|
+
return self.save_script_as()
|
|
895
|
+
try:
|
|
896
|
+
txt = self.editor.toPlainText()
|
|
897
|
+
|
|
898
|
+
# Normalize tabs and NBSP on save
|
|
899
|
+
txt = txt.replace("\u00A0", " ")
|
|
900
|
+
txt = txt.replace("\t", self.editor.INDENT)
|
|
901
|
+
|
|
902
|
+
self._current_path.write_text(txt, encoding="utf-8")
|
|
903
|
+
|
|
904
|
+
# If we modified text, reflect it in editor so user sees reality
|
|
905
|
+
if txt != self.editor.toPlainText():
|
|
906
|
+
self.editor.blockSignals(True)
|
|
907
|
+
self.editor.setPlainText(txt)
|
|
908
|
+
self.editor.blockSignals(False)
|
|
909
|
+
|
|
910
|
+
self._dirty = False
|
|
911
|
+
self._update_title()
|
|
912
|
+
self.reload_scripts() # refresh menu + list
|
|
913
|
+
return True
|
|
914
|
+
except Exception as e:
|
|
915
|
+
QMessageBox.critical(self, "Save failed", str(e))
|
|
916
|
+
return False
|
|
917
|
+
|
|
918
|
+
def save_script_as(self) -> bool:
|
|
919
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
920
|
+
self, "Save Script As", str(self.scripts_dir),
|
|
921
|
+
"Python Script (*.py)"
|
|
922
|
+
)
|
|
923
|
+
if not path:
|
|
924
|
+
return False
|
|
925
|
+
p = Path(path)
|
|
926
|
+
if p.suffix.lower() != ".py":
|
|
927
|
+
p = p.with_suffix(".py")
|
|
928
|
+
self._current_path = p
|
|
929
|
+
return self.save_script()
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def delete_script(self):
|
|
934
|
+
"""
|
|
935
|
+
Permanently delete a script file from disk.
|
|
936
|
+
Priority:
|
|
937
|
+
1) currently selected item in list
|
|
938
|
+
2) currently open script
|
|
939
|
+
"""
|
|
940
|
+
# If current doc is dirty and we're about to delete it, give chance to save/cancel
|
|
941
|
+
# Determine target
|
|
942
|
+
target: Path | None = None
|
|
943
|
+
|
|
944
|
+
it = self.list_scripts.currentItem()
|
|
945
|
+
if it is not None:
|
|
946
|
+
p = self.scripts_dir / it.text()
|
|
947
|
+
if p.exists():
|
|
948
|
+
target = p
|
|
949
|
+
|
|
950
|
+
if target is None and self._current_path is not None and self._current_path.exists():
|
|
951
|
+
target = self._current_path
|
|
952
|
+
|
|
953
|
+
if target is None or not target.exists():
|
|
954
|
+
QMessageBox.information(self, "Delete Script", "No script selected or loaded.")
|
|
955
|
+
return
|
|
956
|
+
|
|
957
|
+
# If deleting the currently open script and it's dirty, ask first
|
|
958
|
+
try:
|
|
959
|
+
if (
|
|
960
|
+
self._current_path is not None
|
|
961
|
+
and target.resolve() == self._current_path.resolve()
|
|
962
|
+
and self._dirty
|
|
963
|
+
):
|
|
964
|
+
if not self.maybe_save_dirty():
|
|
965
|
+
return
|
|
966
|
+
except Exception:
|
|
967
|
+
# resolve() can fail on weird paths; ignore and proceed
|
|
968
|
+
if self._dirty:
|
|
969
|
+
if not self.maybe_save_dirty():
|
|
970
|
+
return
|
|
971
|
+
|
|
972
|
+
name = target.name
|
|
973
|
+
r = QMessageBox.warning(
|
|
974
|
+
self,
|
|
975
|
+
"Delete Script",
|
|
976
|
+
f"Delete '{name}' permanently?\n\n"
|
|
977
|
+
"This will remove the file from disk and cannot be undone.",
|
|
978
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
979
|
+
QMessageBox.StandardButton.No
|
|
980
|
+
)
|
|
981
|
+
if r != QMessageBox.StandardButton.Yes:
|
|
982
|
+
return
|
|
983
|
+
|
|
984
|
+
try:
|
|
985
|
+
target.unlink()
|
|
986
|
+
except Exception as e:
|
|
987
|
+
QMessageBox.critical(self, "Delete Failed", f"Could not delete '{name}':\n{e}")
|
|
988
|
+
return
|
|
989
|
+
|
|
990
|
+
# If we deleted the open script, clear editor state
|
|
991
|
+
try:
|
|
992
|
+
if self._current_path is not None and target.resolve() == self._current_path.resolve():
|
|
993
|
+
self.editor.blockSignals(True)
|
|
994
|
+
self.editor.setPlainText("")
|
|
995
|
+
self.editor.blockSignals(False)
|
|
996
|
+
self._current_path = None
|
|
997
|
+
self._dirty = False
|
|
998
|
+
self._update_title()
|
|
999
|
+
except Exception:
|
|
1000
|
+
pass
|
|
1001
|
+
|
|
1002
|
+
self.reload_scripts()
|
|
1003
|
+
self._log(f"Deleted script: {name}")
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
# ------------------------------------------------------------------
|
|
1007
|
+
# running
|
|
1008
|
+
def run_script(self, *, on_base: bool):
|
|
1009
|
+
if self._current_path is None:
|
|
1010
|
+
QMessageBox.information(self, "Run Script", "No script is loaded.")
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
# autosave before run
|
|
1014
|
+
if self._dirty:
|
|
1015
|
+
ok = self.save_script()
|
|
1016
|
+
if not ok:
|
|
1017
|
+
return
|
|
1018
|
+
|
|
1019
|
+
self.output.setPlainText("")
|
|
1020
|
+
self._log(f"Running {self._current_path.name} (on_base={on_base})")
|
|
1021
|
+
|
|
1022
|
+
# ---- PRE-FLIGHT: compile & indentation sanity ----
|
|
1023
|
+
src = self.editor.toPlainText()
|
|
1024
|
+
try:
|
|
1025
|
+
# 1) Python parser check (catches IndentationError immediately)
|
|
1026
|
+
compile(src, str(self._current_path), "exec")
|
|
1027
|
+
|
|
1028
|
+
# 2) tabnanny mixed-indent check (more specific warnings)
|
|
1029
|
+
import tabnanny
|
|
1030
|
+
import tempfile
|
|
1031
|
+
import os
|
|
1032
|
+
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8") as tf:
|
|
1033
|
+
tf.write(src)
|
|
1034
|
+
tmp_name = tf.name
|
|
1035
|
+
try:
|
|
1036
|
+
tabnanny.check(tmp_name)
|
|
1037
|
+
finally:
|
|
1038
|
+
try: os.remove(tmp_name)
|
|
1039
|
+
except Exception as e:
|
|
1040
|
+
import logging
|
|
1041
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1042
|
+
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
tb = traceback.format_exc()
|
|
1045
|
+
self.output.appendPlainText(tb)
|
|
1046
|
+
QMessageBox.critical(
|
|
1047
|
+
self,
|
|
1048
|
+
"Indentation / Syntax Error",
|
|
1049
|
+
"This script has a Python parse or indentation problem.\n\n"
|
|
1050
|
+
f"{e}\n\n"
|
|
1051
|
+
"Fix it before running."
|
|
1052
|
+
)
|
|
1053
|
+
return
|
|
1054
|
+
|
|
1055
|
+
# ---- RUN ----
|
|
1056
|
+
try:
|
|
1057
|
+
man = getattr(self.app, "scriptman", None)
|
|
1058
|
+
if man is None:
|
|
1059
|
+
raise RuntimeError("ScriptManager not initialized on main window.")
|
|
1060
|
+
|
|
1061
|
+
entry = man.load_script_from_path(self._current_path)
|
|
1062
|
+
|
|
1063
|
+
if entry is None or entry.run is None:
|
|
1064
|
+
raise RuntimeError("Script has no run(ctx).")
|
|
1065
|
+
|
|
1066
|
+
with _StdCapture() as cap:
|
|
1067
|
+
man.run_entry(entry, on_base=on_base)
|
|
1068
|
+
|
|
1069
|
+
out = cap.text().strip()
|
|
1070
|
+
if out:
|
|
1071
|
+
self.output.appendPlainText(out)
|
|
1072
|
+
|
|
1073
|
+
except Exception:
|
|
1074
|
+
tb = traceback.format_exc()
|
|
1075
|
+
self.output.appendPlainText(tb)
|
|
1076
|
+
self._log("Script ERROR:\n" + tb)
|
|
1077
|
+
|
|
1078
|
+
def load_script_from_path(self, path: Path) -> ScriptEntry | None:
|
|
1079
|
+
scripts_root = get_scripts_dir()
|
|
1080
|
+
return self._load_one_script(path, scripts_root)
|
|
1081
|
+
|
|
1082
|
+
# ------------------------------------------------------------------
|
|
1083
|
+
# ui helpers
|
|
1084
|
+
def _mark_dirty(self):
|
|
1085
|
+
if self._current_path is None:
|
|
1086
|
+
self._dirty = True
|
|
1087
|
+
else:
|
|
1088
|
+
self._dirty = True
|
|
1089
|
+
self._update_title()
|
|
1090
|
+
|
|
1091
|
+
def _update_title(self):
|
|
1092
|
+
name = self._current_path.name if self._current_path else "Untitled"
|
|
1093
|
+
star = " *" if self._dirty else ""
|
|
1094
|
+
self.lbl_file.setText(f"{name}{star}")
|
|
1095
|
+
|
|
1096
|
+
def copy_all_output(self):
|
|
1097
|
+
self.output.selectAll()
|
|
1098
|
+
self.output.copy()
|
|
1099
|
+
self.output.moveCursor(self.output.textCursor().End)
|
|
1100
|
+
|
|
1101
|
+
def _log(self, s: str):
|
|
1102
|
+
try:
|
|
1103
|
+
self.app._log(f"[ScriptEditor] {s}")
|
|
1104
|
+
except Exception:
|
|
1105
|
+
print("[ScriptEditor]", s)
|