setiastrosuitepro 1.6.2.post1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (367) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1102 @@
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_one_script(self._current_path)
1062
+ if entry is None or entry.run is None:
1063
+ raise RuntimeError("Script has no run(ctx).")
1064
+
1065
+ with _StdCapture() as cap:
1066
+ man.run_entry(entry, on_base=on_base)
1067
+
1068
+ out = cap.text().strip()
1069
+ if out:
1070
+ self.output.appendPlainText(out)
1071
+
1072
+ except Exception:
1073
+ tb = traceback.format_exc()
1074
+ self.output.appendPlainText(tb)
1075
+ self._log("Script ERROR:\n" + tb)
1076
+
1077
+
1078
+
1079
+ # ------------------------------------------------------------------
1080
+ # ui helpers
1081
+ def _mark_dirty(self):
1082
+ if self._current_path is None:
1083
+ self._dirty = True
1084
+ else:
1085
+ self._dirty = True
1086
+ self._update_title()
1087
+
1088
+ def _update_title(self):
1089
+ name = self._current_path.name if self._current_path else "Untitled"
1090
+ star = " *" if self._dirty else ""
1091
+ self.lbl_file.setText(f"{name}{star}")
1092
+
1093
+ def copy_all_output(self):
1094
+ self.output.selectAll()
1095
+ self.output.copy()
1096
+ self.output.moveCursor(self.output.textCursor().End)
1097
+
1098
+ def _log(self, s: str):
1099
+ try:
1100
+ self.app._log(f"[ScriptEditor] {s}")
1101
+ except Exception:
1102
+ print("[ScriptEditor]", s)