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.
Files changed (368) 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/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,538 @@
1
+ # pro/stat_stretch.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtCore import Qt, QSize, QEvent
4
+ from PyQt6.QtWidgets import (
5
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QDoubleSpinBox,
6
+ QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QSlider, QToolBar, QToolButton
7
+ )
8
+ from PyQt6.QtGui import QImage, QPixmap, QMouseEvent, QCursor
9
+ import numpy as np
10
+ from PyQt6 import sip
11
+
12
+ from .doc_manager import ImageDocument
13
+ # use your existing stretch code
14
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
15
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
16
+
17
+ class StatisticalStretchDialog(QDialog):
18
+ """
19
+ Non-destructive preview; Apply commits to the document image.
20
+ """
21
+ def __init__(self, parent, document: ImageDocument):
22
+ super().__init__(parent)
23
+ self.setWindowTitle(self.tr("Statistical Stretch"))
24
+
25
+ # --- IMPORTANT: avoid “attached modal” behavior on some Linux WMs ---
26
+ # Make this a proper top-level window (tool-style) rather than an attached sheet.
27
+ self.setWindowFlag(Qt.WindowType.Window, True)
28
+ # Non-modal: allow user to switch between images while dialog is open
29
+ self.setWindowModality(Qt.WindowModality.NonModal)
30
+ # Don’t let the generic modal flag override the explicit modality
31
+ self.setModal(False)
32
+
33
+ self._main = parent
34
+ self.doc = document
35
+ self._last_preview = None
36
+
37
+ # Connect to active document change signal
38
+ if hasattr(self._main, "currentDocumentChanged"):
39
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
40
+ self._panning = False
41
+ self._pan_last = None # QPoint
42
+ self._preview_scale = 1.0 # NEW: zoom factor for preview
43
+ self._preview_qimg = None # NEW: store unscaled QImage for clean scaling
44
+ self._suppress_replay_record = False
45
+
46
+ # --- Controls ---
47
+ self.spin_target = QDoubleSpinBox()
48
+ self.spin_target.setRange(0.01, 0.99)
49
+ self.spin_target.setSingleStep(0.01)
50
+ self.spin_target.setValue(0.25)
51
+ self.spin_target.setDecimals(3)
52
+
53
+ self.chk_linked = QCheckBox(self.tr("Linked channels"))
54
+ self.chk_linked.setChecked(False)
55
+
56
+ self.chk_normalize = QCheckBox(self.tr("Normalize to [0..1]"))
57
+ self.chk_normalize.setChecked(False)
58
+
59
+ # NEW: Curves boost
60
+ self.chk_curves = QCheckBox(self.tr("Curves boost"))
61
+ self.chk_curves.setChecked(False)
62
+
63
+ self.curves_row = QWidget()
64
+ cr_lay = QHBoxLayout(self.curves_row); cr_lay.setContentsMargins(0,0,0,0)
65
+ cr_lay.setSpacing(8)
66
+ cr_lay.addWidget(QLabel(self.tr("Strength:")))
67
+ self.sld_curves = QSlider(Qt.Orientation.Horizontal)
68
+ self.sld_curves.setRange(0, 100) # 0.00 … 1.00 mapped to 0…100
69
+ self.sld_curves.setSingleStep(1)
70
+ self.sld_curves.setPageStep(5)
71
+ self.sld_curves.setValue(20) # default 0.20
72
+ self.lbl_curves_val = QLabel("0.20")
73
+ self.sld_curves.valueChanged.connect(lambda v: self.lbl_curves_val.setText(f"{v/100:.2f}"))
74
+ cr_lay.addWidget(self.sld_curves, 1)
75
+ cr_lay.addWidget(self.lbl_curves_val)
76
+ self.curves_row.setEnabled(False) # disabled until checkbox is ticked
77
+ self.chk_curves.toggled.connect(self.curves_row.setEnabled)
78
+
79
+ # Preview area
80
+ self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
81
+ self.preview_label.setMinimumSize(QSize(320, 240))
82
+ self.preview_label.setScaledContents(False)
83
+ self.preview_scroll = QScrollArea()
84
+ self.preview_scroll.setWidgetResizable(False) # <- was True; we manage size
85
+ self.preview_scroll.setWidget(self.preview_label)
86
+ self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
87
+ self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
88
+
89
+ self._fit_mode = True # NEW: start in Fit mode
90
+
91
+ # --- Zoom buttons row (place before the main layout or right above preview) ---
92
+ # --- Zoom buttons row ---
93
+ zoom_row = QHBoxLayout()
94
+
95
+ # Use themed tool buttons (consistent with the rest of SASpro)
96
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
97
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
98
+ self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
99
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
100
+
101
+
102
+ zoom_row.addStretch(1)
103
+ for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
104
+ zoom_row.addWidget(b)
105
+ zoom_row.addStretch(1)
106
+
107
+ # Buttons
108
+ self.btn_preview = QPushButton(self.tr("Preview"))
109
+ self.btn_apply = QPushButton(self.tr("Apply"))
110
+ self.btn_close = QPushButton(self.tr("Close"))
111
+
112
+ self.btn_preview.clicked.connect(self._do_preview)
113
+ self.btn_apply.clicked.connect(self._do_apply)
114
+ self.btn_close.clicked.connect(self.close)
115
+
116
+ # --- Layout ---
117
+ form = QFormLayout()
118
+ form.addRow(self.tr("Target median:"), self.spin_target)
119
+ form.addRow("", self.chk_linked)
120
+ form.addRow("", self.chk_normalize)
121
+ form.addRow("", self.chk_curves)
122
+ form.addRow("", self.curves_row)
123
+
124
+ left = QVBoxLayout()
125
+ left.addLayout(form)
126
+ row = QHBoxLayout()
127
+ row.addWidget(self.btn_preview)
128
+ row.addWidget(self.btn_apply)
129
+ row.addStretch(1)
130
+ left.addLayout(row)
131
+ left.addStretch(1)
132
+
133
+ main = QHBoxLayout(self)
134
+ main.addLayout(left, 0)
135
+
136
+ # NEW: right column with zoom row + preview
137
+ right = QVBoxLayout()
138
+ right.addLayout(zoom_row) # ← actually add the zoom controls
139
+ right.addWidget(self.preview_scroll, 1) # preview below the buttons
140
+ main.addLayout(right, 1)
141
+
142
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
143
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
144
+ self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
145
+ self.btn_zoom_fit.clicked.connect(self._fit_preview)
146
+
147
+ self.preview_scroll.viewport().installEventFilter(self)
148
+ self.preview_label.installEventFilter(self)
149
+
150
+ self._populate_initial_preview()
151
+
152
+ # ----- helpers -----
153
+ def _get_source_float(self) -> np.ndarray:
154
+ """
155
+ Return a float32 array scaled into ~[0..1] for stretching.
156
+ """
157
+ src = np.asarray(self.doc.image)
158
+ if src is None or src.size == 0:
159
+ return None
160
+
161
+ if np.issubdtype(src.dtype, np.integer):
162
+ # Assume 16-bit astro sources by default; adjust if you prefer
163
+ scale = 65535.0 if src.dtype.itemsize >= 2 else 255.0
164
+ return (src.astype(np.float32) / scale).clip(0, 1)
165
+ else:
166
+ f = src.astype(np.float32)
167
+ # If values are way above 1 (linear calibrated data), compress softly
168
+ mx = float(f.max()) if f.size else 1.0
169
+ if mx > 5.0:
170
+ f = f / mx
171
+ return f
172
+
173
+ def _apply_current_zoom(self):
174
+ """Apply the current zoom mode (fit or manual) to the preview image."""
175
+ if self._preview_qimg is None:
176
+ return
177
+ if self._fit_mode:
178
+ self._fit_preview()
179
+ else:
180
+ self._update_preview_scaled()
181
+
182
+ def _fit_preview(self):
183
+ """Fit the image into the visible scroll viewport."""
184
+ if self._preview_qimg is None:
185
+ return
186
+ vp = self.preview_scroll.viewport().size()
187
+ if vp.width() <= 1 or vp.height() <= 1:
188
+ return
189
+ iw, ih = self._preview_qimg.width(), self._preview_qimg.height()
190
+ if iw <= 0 or ih <= 0:
191
+ return
192
+ # compute scale to fit
193
+ sx = vp.width() / iw
194
+ sy = vp.height() / ih
195
+ self._preview_scale = max(0.05, min(sx, sy))
196
+ self._fit_mode = True
197
+ self._update_preview_scaled()
198
+
199
+ def _zoom_reset_100(self):
200
+ """Set zoom to 100% (1:1)."""
201
+ self._fit_mode = False
202
+ self._preview_scale = 1.0
203
+ self._update_preview_scaled()
204
+
205
+ def _zoom_by(self, factor: float):
206
+ """Incremental zoom around the current center; exits Fit mode."""
207
+ self._fit_mode = False
208
+ new_scale = self._preview_scale * float(factor)
209
+ self._preview_scale = max(0.05, min(new_scale, 8.0))
210
+ self._update_preview_scaled()
211
+
212
+
213
+ # --- MASK helpers ----------------------------------------------------
214
+ def _active_mask_array(self) -> np.ndarray | None:
215
+ """Return active mask as float32 [H,W] in 0..1, resized to doc image."""
216
+ try:
217
+ mid = getattr(self.doc, "active_mask_id", None)
218
+ if not mid:
219
+ return None
220
+ layer = getattr(self.doc, "masks", {}).get(mid)
221
+ if layer is None:
222
+ return None
223
+
224
+ m = np.asarray(getattr(layer, "data", None))
225
+ if m is None or m.size == 0:
226
+ return None
227
+
228
+ # squeeze to 2D
229
+ if m.ndim == 3 and m.shape[2] == 1:
230
+ m = m[..., 0]
231
+ elif m.ndim == 3: # RGB/whatever → luminance
232
+ m = (0.2126*m[...,0] + 0.7152*m[...,1] + 0.0722*m[...,2])
233
+
234
+ m = m.astype(np.float32, copy=False)
235
+ # normalize if integer / out-of-range
236
+ if m.dtype.kind in "ui":
237
+ m /= float(np.iinfo(m.dtype).max)
238
+ m = np.clip(m, 0.0, 1.0)
239
+
240
+ th, tw = self.doc.image.shape[:2]
241
+ sh, sw = m.shape[:2]
242
+ if (sh, sw) != (th, tw):
243
+ yi = (np.linspace(0, sh-1, th)).astype(np.int32)
244
+ xi = (np.linspace(0, sw-1, tw)).astype(np.int32)
245
+ m = m[yi][:, xi]
246
+
247
+ # honor opacity if present
248
+ opacity = float(getattr(layer, "opacity", 1.0) or 1.0)
249
+ if opacity < 1.0:
250
+ m *= opacity
251
+ return m
252
+ except Exception:
253
+ return None
254
+
255
+ def _blend_with_mask(self, base: np.ndarray, out: np.ndarray, mask: np.ndarray) -> np.ndarray:
256
+ """base/out can be mono or 3ch; mask is [H,W] in 0..1."""
257
+ if out.ndim == 3 and out.shape[2] == 3:
258
+ m = mask[..., None]
259
+ else:
260
+ m = mask
261
+ return base * (1.0 - m) + out * m
262
+
263
+
264
+ def _run_stretch(self) -> np.ndarray | None:
265
+ imgf = self._get_source_float()
266
+ if imgf is None:
267
+ return None
268
+
269
+ target = float(self.spin_target.value())
270
+ linked = bool(self.chk_linked.isChecked())
271
+ normalize = bool(self.chk_normalize.isChecked())
272
+ apply_curves = bool(self.chk_curves.isChecked())
273
+ curves_boost = float(self.sld_curves.value()) / 100.0
274
+
275
+ if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
276
+ out = stretch_mono_image(
277
+ imgf.squeeze(),
278
+ target_median=target,
279
+ normalize=normalize,
280
+ apply_curves=apply_curves,
281
+ curves_boost=curves_boost,
282
+ )
283
+ else:
284
+ out = stretch_color_image(
285
+ imgf,
286
+ target_median=target,
287
+ linked=linked,
288
+ normalize=normalize,
289
+ apply_curves=apply_curves,
290
+ curves_boost=curves_boost,
291
+ )
292
+
293
+ # ✅ If a mask is active, blend stretched result with original
294
+ m = self._active_mask_array()
295
+ if m is not None:
296
+ base = imgf.astype(np.float32, copy=False)
297
+ out = self._blend_with_mask(base, out, m)
298
+
299
+ return out
300
+
301
+
302
+ def _set_preview_pixmap(self, arr: np.ndarray):
303
+ vis = arr
304
+ if vis is None or vis.size == 0:
305
+ self.preview_label.clear()
306
+ return
307
+
308
+ # Ensure 3 channels for display
309
+ if vis.ndim == 2:
310
+ vis3 = np.stack([vis] * 3, axis=-1)
311
+ elif vis.ndim == 3 and vis.shape[2] == 1:
312
+ vis3 = np.repeat(vis, 3, axis=2)
313
+ else:
314
+ vis3 = vis
315
+
316
+ # Convert to 8-bit RGB
317
+ if vis3.dtype == np.uint8:
318
+ buf8 = vis3
319
+ elif vis3.dtype == np.uint16:
320
+ buf8 = (vis3.astype(np.float32) / 65535.0 * 255.0).clip(0, 255).astype(np.uint8)
321
+ else:
322
+ buf8 = (np.clip(vis3, 0.0, 1.0) * 255.0).astype(np.uint8)
323
+
324
+ # Must be C-contiguous for QImage
325
+ buf8 = np.ascontiguousarray(buf8)
326
+ h, w, _ = buf8.shape
327
+ bytes_per_line = buf8.strides[0]
328
+
329
+ # Build QImage from raw pointer; keep references alive
330
+ self._last_preview = buf8 # keep backing store alive
331
+ ptr = sip.voidptr(self._last_preview.ctypes.data)
332
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
333
+
334
+ self._preview_qimg = qimg
335
+ self._apply_current_zoom()
336
+
337
+ # ----- active document change -----
338
+ def _on_active_doc_changed(self, doc):
339
+ """Called when user clicks a different image window."""
340
+ if doc is None or getattr(doc, "image", None) is None:
341
+ return
342
+ self.doc = doc
343
+ self._populate_initial_preview()
344
+
345
+ # ----- slots -----
346
+ def _populate_initial_preview(self):
347
+ # show the current (unstretched) image as baseline
348
+ src = self._get_source_float()
349
+ if src is not None:
350
+ self._set_preview_pixmap(np.clip(src, 0, 1))
351
+
352
+ def _do_preview(self):
353
+ try:
354
+ out = self._run_stretch()
355
+ if out is None:
356
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
357
+ return
358
+ self._set_preview_pixmap(out)
359
+ except Exception as e:
360
+ QMessageBox.warning(self, "Preview failed", str(e))
361
+
362
+ def _do_apply(self):
363
+ try:
364
+ out = self._run_stretch()
365
+ if out is None:
366
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
367
+ return
368
+
369
+ # Preserve mono vs color shape
370
+ if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or self.doc.image.shape[-1] == 1):
371
+ out = out[..., 0]
372
+
373
+ # --- Gather current UI state ------------------------------------
374
+ target = float(self.spin_target.value())
375
+ linked = bool(self.chk_linked.isChecked())
376
+ normalize = bool(self.chk_normalize.isChecked())
377
+ apply_curves = bool(getattr(self, "chk_curves", None) and self.chk_curves.isChecked())
378
+ curves_boost = 0.0
379
+ if getattr(self, "sld_curves", None) is not None:
380
+ curves_boost = float(self.sld_curves.value()) / 100.0
381
+
382
+ # Build human-readable step name
383
+ parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
384
+ if normalize:
385
+ parts.append("norm")
386
+ if apply_curves:
387
+ parts.append(f"curves={curves_boost:.2f}")
388
+ if self._active_mask_array() is not None:
389
+ parts.append("masked")
390
+ step_name = f"Statistical Stretch ({', '.join(parts)})"
391
+
392
+ # Apply to document
393
+ self.doc.apply_edit(out.astype(np.float32, copy=False), step_name=step_name)
394
+
395
+ # Turn off display stretch on the active view, if any
396
+ mw = self.parent()
397
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
398
+ view = mw.mdi.activeSubWindow().widget()
399
+ if getattr(view, "autostretch_enabled", False):
400
+ view.set_autostretch(False)
401
+
402
+ # Existing logging, now using the same values as above
403
+ if hasattr(mw, "_log"):
404
+ curves_on = apply_curves
405
+ boost_val = curves_boost if curves_on else 0.0
406
+ mw._log(
407
+ "Applied Statistical Stretch "
408
+ f"(target={target:.3f}, linked={linked}, normalize={normalize}, "
409
+ f"curves={'ON' if curves_on else 'OFF'}"
410
+ f"{', boost='+str(round(boost_val,2)) if curves_on else ''}, "
411
+ f"mask={'ON' if self._active_mask_array() is not None else 'OFF'})"
412
+ )
413
+
414
+ # --- Build preset for headless replay ---------------------------
415
+ # --- Build preset for headless replay ---------------------------
416
+ preset = {
417
+ "target_median": target,
418
+ "linked": linked,
419
+ "normalize": normalize,
420
+ "apply_curves": apply_curves,
421
+ "curves_boost": curves_boost,
422
+ }
423
+
424
+ # ✅ Remember this as the last headless-style command
425
+ # (unless we are in a headless/suppressed call)
426
+ suppress = bool(getattr(self, "_suppress_replay_record", False))
427
+ if not suppress:
428
+ from PyQt6.QtWidgets import QMainWindow
429
+ try:
430
+ mw2 = self.parent()
431
+ while mw2 is not None and not isinstance(mw2, QMainWindow):
432
+ mw2 = mw2.parent()
433
+
434
+ if mw2 is not None and hasattr(mw2, "remember_last_headless_command"):
435
+ mw2.remember_last_headless_command(
436
+ command_id="stat_stretch",
437
+ preset=preset,
438
+ description="Statistical Stretch",
439
+ )
440
+ print(f"Remembered Statistical Stretch last headless command: {preset}")
441
+ else:
442
+ print("No main window with remember_last_headless_command; cannot store stat_stretch preset")
443
+ except Exception as e:
444
+ print(f"Failed to remember Statistical Stretch last headless command: {e}")
445
+ else:
446
+ # optional debug
447
+ print("Statistical Stretch: replay recording suppressed for this apply()")
448
+
449
+ # Dialog stays open so user can apply to other images
450
+ # Update the document reference to reflect the now-active document
451
+ self._refresh_document_from_active()
452
+
453
+
454
+ except Exception as e:
455
+ QMessageBox.critical(self, "Apply failed", str(e))
456
+
457
+ def _refresh_document_from_active(self):
458
+ """
459
+ Refresh the dialog's document reference to the currently active document.
460
+ This allows reusing the same dialog on different images.
461
+ """
462
+ try:
463
+ main = self.parent()
464
+ if main and hasattr(main, "_active_doc"):
465
+ new_doc = main._active_doc()
466
+ if new_doc is not None and new_doc is not self.doc:
467
+ self.doc = new_doc
468
+ # Reset preview state for new document
469
+ self._last_preview = None
470
+ self._preview_qimg = None
471
+ except Exception:
472
+ pass
473
+
474
+
475
+ def _update_preview_scaled(self):
476
+ if self._preview_qimg is None:
477
+ self.preview_label.clear()
478
+ return
479
+ sw = max(1, int(self._preview_qimg.width() * self._preview_scale))
480
+ sh = max(1, int(self._preview_qimg.height() * self._preview_scale))
481
+ scaled = self._preview_qimg.scaled(
482
+ sw, sh,
483
+ Qt.AspectRatioMode.KeepAspectRatio,
484
+ Qt.TransformationMode.SmoothTransformation
485
+ )
486
+ self.preview_label.setPixmap(QPixmap.fromImage(scaled))
487
+ self.preview_label.resize(scaled.size()) # <- crucial for scrollbars
488
+
489
+ def resizeEvent(self, ev):
490
+ super().resizeEvent(ev)
491
+ if self._fit_mode:
492
+ self._fit_preview()
493
+
494
+ def eventFilter(self, obj, ev):
495
+ # Ctrl+wheel zoom
496
+ if ev.type() == QEvent.Type.Wheel and (obj is self.preview_scroll.viewport() or obj is self.preview_label):
497
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
498
+ factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
499
+ self._fit_mode = False # ← ensure we exit Fit mode
500
+ self._preview_scale = max(0.05, min(self._preview_scale * factor, 8.0))
501
+ self._update_preview_scaled()
502
+ return True
503
+ return False
504
+
505
+ # Click+drag pan (left or middle mouse)
506
+ if obj is self.preview_scroll.viewport() or obj is self.preview_label:
507
+ if ev.type() == QEvent.Type.MouseButtonPress:
508
+ if ev.buttons() & (Qt.MouseButton.LeftButton | Qt.MouseButton.MiddleButton):
509
+ self._panning = True
510
+ self._pan_last = ev.position().toPoint()
511
+ # show a "grab" cursor where the drag begins
512
+ if obj is self.preview_label:
513
+ self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor)
514
+ else:
515
+ self.preview_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
516
+ return True
517
+
518
+ elif ev.type() == QEvent.Type.MouseMove and self._panning:
519
+ pos = ev.position().toPoint()
520
+ delta = pos - self._pan_last
521
+ self._pan_last = pos
522
+
523
+ hsb = self.preview_scroll.horizontalScrollBar()
524
+ vsb = self.preview_scroll.verticalScrollBar()
525
+ hsb.setValue(hsb.value() - delta.x())
526
+ vsb.setValue(vsb.value() - delta.y())
527
+ return True
528
+
529
+ elif ev.type() == QEvent.Type.MouseButtonRelease and self._panning:
530
+ self._panning = False
531
+ self._pan_last = None
532
+ # restore cursor
533
+ self.preview_label.unsetCursor()
534
+ self.preview_scroll.viewport().unsetCursor()
535
+ return True
536
+
537
+ return super().eventFilter(obj, ev)
538
+
@@ -0,0 +1,78 @@
1
+ # pro/status_log_dock.py
2
+ from PyQt6.QtCore import Qt, pyqtSlot
3
+ from PyQt6.QtGui import QTextCursor
4
+ from PyQt6.QtWidgets import (
5
+ QDockWidget, QWidget, QVBoxLayout, QPlainTextEdit, QPushButton, QHBoxLayout
6
+ )
7
+
8
+ class StatusLogDock(QDockWidget):
9
+ MAX_BLOCKS = 2000
10
+
11
+ def __init__(self, parent=None):
12
+ super().__init__(self.tr("Stacking Log"), parent)
13
+ self.setObjectName("StackingLogDock")
14
+ self.setAllowedAreas(
15
+ Qt.DockWidgetArea.BottomDockWidgetArea
16
+ | Qt.DockWidgetArea.LeftDockWidgetArea
17
+ | Qt.DockWidgetArea.RightDockWidgetArea
18
+ )
19
+
20
+ w = QWidget(self)
21
+ lay = QVBoxLayout(w); lay.setContentsMargins(6,6,6,6)
22
+
23
+ self.view = QPlainTextEdit(w)
24
+ self.view.setReadOnly(True)
25
+ self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
26
+ self.view.setStyleSheet(
27
+ "background-color: black; color: white; font-family: Monospace; padding: 6px;"
28
+ )
29
+ lay.addWidget(self.view, 1)
30
+
31
+ row = QHBoxLayout()
32
+ btn_clear = QPushButton("Clear", w)
33
+ btn_clear.clicked.connect(self.view.clear)
34
+ row.addWidget(btn_clear)
35
+ row.addStretch(1)
36
+ lay.addLayout(row)
37
+
38
+ self.setWidget(w)
39
+
40
+ @pyqtSlot(str)
41
+ def append_line(self, message: str):
42
+ doc = self.view.document()
43
+
44
+ # coalesce “Normalizing …” lines (replace last if same prefix)
45
+ if message.startswith("🔄 Normalizing") and doc.blockCount() > 0:
46
+ last = doc.findBlockByNumber(doc.blockCount() - 1)
47
+ if last.isValid() and last.text().startswith("🔄 Normalizing"):
48
+ cur = self.view.textCursor()
49
+ cur.movePosition(QTextCursor.MoveOperation.End)
50
+ cur.movePosition(QTextCursor.MoveOperation.StartOfBlock,
51
+ QTextCursor.MoveMode.KeepAnchor)
52
+ cur.removeSelectedText()
53
+ cur.insertText(message)
54
+ self.view.setTextCursor(cur)
55
+ else:
56
+ self.view.appendPlainText(message)
57
+ else:
58
+ self.view.appendPlainText(message)
59
+
60
+ # trim earliest lines
61
+ if doc.blockCount() > self.MAX_BLOCKS:
62
+ extra = doc.blockCount() - self.MAX_BLOCKS
63
+ cur = self.view.textCursor()
64
+ cur.movePosition(QTextCursor.MoveOperation.Start)
65
+ cur.movePosition(QTextCursor.MoveOperation.Down,
66
+ QTextCursor.MoveMode.KeepAnchor, extra)
67
+ cur.removeSelectedText()
68
+ self.view.setTextCursor(self.view.textCursor())
69
+
70
+ # autoscroll
71
+ sb = self.view.verticalScrollBar()
72
+ sb.setValue(sb.maximum())
73
+
74
+ def show_raise(self):
75
+ self.setVisible(True)
76
+ self.raise_()
77
+ if self.widget():
78
+ self.widget().setFocus()