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,3328 @@
1
+ # pro/subwindow.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtCore import Qt, QPoint, pyqtSignal, QSize, QEvent, QByteArray, QMimeData, QSettings, QTimer, QRect, QPoint, QMargins
4
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollArea, QLabel, QToolButton, QHBoxLayout, QMessageBox, QMdiSubWindow, QMenu, QInputDialog, QApplication, QTabWidget, QRubberBand
5
+ from PyQt6.QtGui import QPixmap, QImage, QWheelEvent, QShortcut, QKeySequence, QCursor, QDrag, QGuiApplication
6
+ from PyQt6 import sip
7
+ import numpy as np
8
+ import json
9
+ import math
10
+ import weakref
11
+ import os
12
+ try:
13
+ from PyQt6.QtCore import QSignalBlocker
14
+ except Exception:
15
+ class QSignalBlocker:
16
+ def __init__(self, obj): self.obj = obj
17
+ def __enter__(self):
18
+ try: self.obj.blockSignals(True)
19
+ except Exception as e:
20
+ import logging
21
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
22
+ def __exit__(self, *exc):
23
+ try: self.obj.blockSignals(False)
24
+ except Exception as e:
25
+ import logging
26
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
27
+
28
+ from .autostretch import autostretch # ← uses pro/imageops/stretch.py
29
+
30
+ from setiastro.saspro.dnd_mime import MIME_VIEWSTATE, MIME_MASK, MIME_ASTROMETRY, MIME_CMD, MIME_LINKVIEW
31
+ from setiastro.saspro.shortcuts import _unpack_cmd_payload
32
+ from setiastro.saspro.widgets.image_utils import ensure_contiguous
33
+
34
+ from .layers import composite_stack, ImageLayer, BLEND_MODES
35
+
36
+ # --- NEW: simple table model for TableDocument ---
37
+ from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt, QVariant
38
+
39
+ __all__ = ["ImageSubWindow", "TableSubWindow"]
40
+
41
+ class SimpleTableModel(QAbstractTableModel):
42
+ def __init__(self, rows: list[list], headers: list[str], parent=None):
43
+ super().__init__(parent)
44
+ self._rows = rows
45
+ self._headers = headers
46
+
47
+ def rowCount(self, parent=QModelIndex()) -> int:
48
+ return 0 if parent.isValid() else len(self._rows)
49
+
50
+ def columnCount(self, parent=QModelIndex()) -> int:
51
+ return 0 if parent.isValid() else (len(self._headers) if self._headers else (len(self._rows[0]) if self._rows else 0))
52
+
53
+ def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
54
+ if not index.isValid():
55
+ return QVariant()
56
+ if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
57
+ try:
58
+ return str(self._rows[index.row()][index.column()])
59
+ except Exception:
60
+ return ""
61
+ return QVariant()
62
+
63
+ def headerData(self, section: int, orientation: Qt.Orientation, role=Qt.ItemDataRole.DisplayRole):
64
+ if role != Qt.ItemDataRole.DisplayRole:
65
+ return QVariant()
66
+ if orientation == Qt.Orientation.Horizontal:
67
+ try:
68
+ return self._headers[section] if self._headers and 0 <= section < len(self._headers) else f"C{section+1}"
69
+ except Exception:
70
+ return f"C{section+1}"
71
+ else:
72
+ return str(section + 1)
73
+
74
+
75
+ class _DragTab(QLabel):
76
+ """
77
+ Little grab tab you can drag to copy/sync view state.
78
+ - Drag onto MDI background → duplicate view (same document)
79
+ - Drag onto another subwindow → copy zoom/pan/stretch to that view
80
+ """
81
+ def __init__(self, owner, *args, **kwargs):
82
+ super().__init__(*args, **kwargs)
83
+ self.owner = owner
84
+ self._press_pos = None
85
+ self.setText("⧉")
86
+ self.setToolTip(self.tr(
87
+ "Drag to duplicate/copy view.\n"
88
+ "Hold Alt while dragging to LINK this view with another (live pan/zoom sync).\n"
89
+ "Hold Shift while dragging to drop this image as a mask onto another view.\n"
90
+ "Hold Ctrl while dragging to copy the astrometric solution (WCS) to another view."
91
+ ))
92
+
93
+ self.setFixedSize(22, 18)
94
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
95
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
96
+ self.setStyleSheet(
97
+ "QLabel{background:rgba(255,255,255,30); "
98
+ "border:1px solid rgba(255,255,255,60); border-radius:4px;}"
99
+ )
100
+
101
+ def mousePressEvent(self, ev):
102
+ if ev.button() == Qt.MouseButton.LeftButton:
103
+ self._press_pos = ev.position()
104
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
105
+
106
+
107
+ def mouseMoveEvent(self, ev):
108
+ if self._press_pos is None:
109
+ return
110
+ if (ev.position() - self._press_pos).manhattanLength() > 6:
111
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
112
+ self._press_pos = None
113
+ mods = QApplication.keyboardModifiers()
114
+ if (mods & Qt.KeyboardModifier.AltModifier):
115
+ self.owner._start_link_drag()
116
+ elif (mods & Qt.KeyboardModifier.ShiftModifier):
117
+ print("[DragTab] Shift+drag → start_mask_drag() from", id(self.owner))
118
+ self.owner._start_mask_drag()
119
+ elif (mods & Qt.KeyboardModifier.ControlModifier):
120
+ self.owner._start_astrometry_drag()
121
+ else:
122
+ self.owner._start_viewstate_drag()
123
+
124
+ def mouseReleaseEvent(self, ev):
125
+ self._press_pos = None
126
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
127
+
128
+ MASK_GLYPH = "■"
129
+ #ACTIVE_PREFIX = "Active View: "
130
+ ACTIVE_PREFIX = ""
131
+ GLYPHS = "■●◆▲▪▫•◼◻◾◽🔗"
132
+ LINK_PREFIX = "🔗 "
133
+ DECORATION_PREFIXES = (
134
+ LINK_PREFIX, # "🔗 "
135
+ f"{MASK_GLYPH} ", # "■ "
136
+ "Active View: ", # legacy
137
+ )
138
+
139
+
140
+ from astropy.wcs import WCS as _AstroWCS
141
+ from astropy.io.fits import Header as _FitsHeader
142
+
143
+ def build_celestial_wcs(header) -> _AstroWCS | None:
144
+ """
145
+ Given a FITS-like header or a dict with FITS keywords, return a *2-D celestial*
146
+ astropy.wcs.WCS. Returns None if a sane celestial WCS cannot be recovered.
147
+ Resilient to 3rd axes (RGB/STOKES) and SIP distortions.
148
+
149
+ Accepted `header`:
150
+ * astropy.io.fits.Header
151
+ * dict of FITS cards (string->value)
152
+ * dict containing {"FITSKeywords": {NAME: [{value: ..., comment: ...}], ...}}
153
+ """
154
+ if header is None:
155
+ return None
156
+
157
+ # (A) If we already got a WCS, try to coerce to celestial
158
+ if isinstance(header, _AstroWCS):
159
+ try:
160
+ wc = getattr(header, "celestial", None)
161
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else header
162
+ except Exception:
163
+ return header
164
+
165
+ # (B) Ensure we have a bona-fide FITS Header
166
+ hdr_obj = None
167
+ if isinstance(header, _FitsHeader):
168
+ hdr_obj = header
169
+ elif isinstance(header, dict):
170
+ # XISF-style: {"FITSKeywords": {"CTYPE1":[{"value":"RA---TAN"}], ...}}
171
+ if "FITSKeywords" in header and isinstance(header["FITSKeywords"], dict):
172
+ from astropy.io.fits import Header
173
+ hdr_obj = Header()
174
+ for k, v in header["FITSKeywords"].items():
175
+ if isinstance(v, list) and v:
176
+ val = v[0].get("value")
177
+ com = v[0].get("comment", "")
178
+ if val is not None:
179
+ try: hdr_obj[str(k)] = (val, com)
180
+ except Exception: hdr_obj[str(k)] = val
181
+ elif v is not None:
182
+ try: hdr_obj[str(k)] = v
183
+ except Exception as e:
184
+ import logging
185
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
186
+ else:
187
+ # Flat dict of FITS-like cards
188
+ from astropy.io.fits import Header
189
+ hdr_obj = Header()
190
+ for k, v in header.items():
191
+ try: hdr_obj[str(k)] = v
192
+ except Exception as e:
193
+ import logging
194
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
195
+
196
+ if hdr_obj is None:
197
+ return None
198
+
199
+ # (C) Try full WCS first
200
+ try:
201
+ w = _AstroWCS(hdr_obj, relax=True)
202
+ wc = getattr(w, "celestial", None)
203
+ if wc is not None and getattr(wc, "naxis", 2) == 2:
204
+ return wc
205
+ if getattr(w, "has_celestial", False):
206
+ return w.celestial
207
+ except Exception:
208
+ w = None
209
+
210
+ # (D) Force a 2-axis interpretation (drop e.g. RGB axis)
211
+ try:
212
+ w2 = _AstroWCS(hdr_obj, relax=True, naxis=2)
213
+ if getattr(w2, "has_celestial", False):
214
+ return w2.celestial
215
+ except Exception:
216
+ pass
217
+
218
+ # (E) As a last resort, scrub obvious axis-3 cards and retry
219
+ try:
220
+ hdr2 = hdr_obj.copy()
221
+ for k in ("CTYPE3","CUNIT3","CRVAL3","CRPIX3",
222
+ "CD3_1","CD3_2","CD3_3","PC3_1","PC3_2","PC3_3"):
223
+ if k in hdr2:
224
+ del hdr2[k]
225
+ w3 = _AstroWCS(hdr2, relax=True, naxis=2)
226
+ if getattr(w3, "has_celestial", False):
227
+ return w3.celestial
228
+ except Exception:
229
+ pass
230
+
231
+ return None
232
+
233
+ def _compute_cropped_wcs(parent_hdr_like, x, y, w, h):
234
+ """
235
+ Build a cropped WCS header from parent_hdr_like and ROI (x,y,w,h).
236
+
237
+ IMPORTANT:
238
+ - If the parent header already describes a cropped ROI (NAXIS1/2 already
239
+ equal to w/h, or the ROI is obviously outside the parent NAXIS), we
240
+ *do not* shift CRPIX again. We just return a copy of the parent header,
241
+ marking it as ROI-CROP if needed.
242
+ """
243
+ # Normalize ROI values to ints
244
+ x = int(x)
245
+ y = int(y)
246
+ w = int(w)
247
+ h = int(h)
248
+
249
+ # Same helper as before; safe on dict/FITS Header
250
+ try:
251
+ from astropy.io.fits import Header
252
+ except Exception:
253
+ Header = None
254
+
255
+ if Header is not None and isinstance(parent_hdr_like, Header):
256
+ base = {k: parent_hdr_like.get(k) for k in parent_hdr_like.keys()}
257
+ elif isinstance(parent_hdr_like, dict):
258
+ fk = parent_hdr_like.get("FITSKeywords")
259
+ if isinstance(fk, dict) and fk:
260
+ base = {}
261
+ for k, arr in fk.items():
262
+ try:
263
+ base[k] = (arr or [{}])[0].get("value", None)
264
+ except Exception:
265
+ pass
266
+ else:
267
+ base = dict(parent_hdr_like)
268
+ else:
269
+ base = {}
270
+
271
+ # ------------------------------------------------------------------
272
+ # Detect "already cropped" headers to avoid double-shifting CRPIX.
273
+ # ------------------------------------------------------------------
274
+ nax1 = base.get("NAXIS1")
275
+ nax2 = base.get("NAXIS2")
276
+
277
+ if isinstance(nax1, (int, float)) and isinstance(nax2, (int, float)):
278
+ n1 = int(nax1)
279
+ n2 = int(nax2)
280
+
281
+ # Case A: parent already has same size as requested ROI,
282
+ # but x,y are non-zero → this smells like ROI-of-ROI.
283
+ if w == n1 and h == n2 and (x != 0 or y != 0):
284
+
285
+ base["NAXIS1"], base["NAXIS2"] = n1, n2
286
+ base.setdefault("CROPX", 0)
287
+ base.setdefault("CROPY", 0)
288
+ base.setdefault("SASKIND", "ROI-CROP")
289
+ return base
290
+
291
+ # Case B: ROI clearly outside parent dimensions → also treat as
292
+ # "already cropped, don't touch CRPIX".
293
+ if x >= n1 or y >= n2 or x + w > n1 or y + h > n2:
294
+
295
+ base["NAXIS1"], base["NAXIS2"] = n1, n2
296
+ base.setdefault("CROPX", 0)
297
+ base.setdefault("CROPY", 0)
298
+ base.setdefault("SASKIND", "ROI-CROP")
299
+ return base
300
+
301
+ # ------------------------------------------------------------------
302
+ # Normal behavior: real crop relative to full-frame parent.
303
+ # ------------------------------------------------------------------
304
+ c1, c2 = base.get("CRPIX1"), base.get("CRPIX2")
305
+ if isinstance(c1, (int, float)) and isinstance(c2, (int, float)):
306
+ base["CRPIX1"] = float(c1) - float(x)
307
+ base["CRPIX2"] = float(c2) - float(y)
308
+
309
+ base["NAXIS1"], base["NAXIS2"] = w, h
310
+ base["CROPX"], base["CROPY"] = x, y
311
+ base["SASKIND"] = "ROI-CROP"
312
+ return base
313
+
314
+
315
+
316
+ class ImageSubWindow(QWidget):
317
+ aboutToClose = pyqtSignal(object)
318
+ autostretchChanged = pyqtSignal(bool)
319
+ requestDuplicate = pyqtSignal(object) # document
320
+ layers_changed = pyqtSignal()
321
+ autostretchProfileChanged = pyqtSignal(str)
322
+ viewTitleChanged = pyqtSignal(object, str)
323
+ activeSourceChanged = pyqtSignal(object) # None for full, or (x,y,w,h) for ROI
324
+ viewTransformChanged = pyqtSignal(float, int, int)
325
+ _registry = weakref.WeakValueDictionary()
326
+ resized = pyqtSignal()
327
+ replayOnBaseRequested = pyqtSignal(object)
328
+
329
+
330
+ def __init__(self, document, parent=None):
331
+ super().__init__(parent)
332
+ self._base_document = None
333
+ self.document = document
334
+ self._last_title_for_emit = None
335
+
336
+ # ─────────────────────────────────────────────────────────
337
+ # View / render state
338
+ # ─────────────────────────────────────────────────────────
339
+ self._min_scale = 0.02
340
+ self._max_scale = 3.00 # 300%
341
+ self.scale = 0.25
342
+ self._dragging = False
343
+ self._drag_start = QPoint()
344
+ self._autostretch_linked = QSettings().value("display/stretch_linked", False, type=bool)
345
+ self.autostretch_enabled = False
346
+ self.autostretch_target = 0.25
347
+ self.autostretch_sigma = 3.0
348
+ self.autostretch_profile = "normal"
349
+ self.show_mask_overlay = False
350
+ self._mask_overlay_alpha = 0.5 # 0..1
351
+ self._mask_overlay_invert = True
352
+ self._layers: list[ImageLayer] = []
353
+ self.layers_changed.connect(lambda: None)
354
+ self._display_override: np.ndarray | None = None
355
+ self._readout_hint_shown = False
356
+ self._link_emit_timer = QTimer(self)
357
+ self._link_emit_timer.setSingleShot(True)
358
+ self._link_emit_timer.setInterval(100) # tweak 120–250ms to taste
359
+ self._link_emit_timer.timeout.connect(self._emit_view_transform_now)
360
+ self._suppress_link_emit = False # guard while applying remote updates
361
+ self._link_squelch = False # prevents feedback on linked apply
362
+ self._pan_live = False
363
+ self._linked_views = weakref.WeakSet()
364
+ ImageSubWindow._registry[id(self)] = self
365
+ self._link_badge_on = False
366
+
367
+
368
+
369
+ # whenever we move/zoom, relay to linked peers
370
+ self.viewTransformChanged.connect(self._relay_to_linked)
371
+ # pixel readout live-probe state
372
+ self._space_down = False
373
+ self._readout_dragging = False
374
+ # Pinch gesture state (macOS trackpad)
375
+ self._gesture_zoom_start = None
376
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
377
+
378
+ # Title (doc/view) sync
379
+ self._view_title_override = None
380
+ self.document.changed.connect(self._sync_host_title)
381
+ self._sync_host_title()
382
+ self.document.changed.connect(self._refresh_local_undo_buttons)
383
+
384
+ # Cached display buffer
385
+ self._buf8 = None # backing np.uint8 [H,W,3]
386
+ self._qimg_src = None # QImage wrapping _buf8
387
+
388
+ # Keep mask visuals in sync when doc changes
389
+ self.document.changed.connect(self._on_doc_mask_changed)
390
+
391
+ # ─────────────────────────────────────────────────────────
392
+ # Preview tabs state
393
+ # ─────────────────────────────────────────────────────────
394
+ self._tabs: QTabWidget | None = None
395
+ self._previews: list[dict] = [] # {"id": int, "name": str, "roi": (x,y,w,h), "arr": np.ndarray}
396
+ self._active_source_kind = "full" # "full" | "preview"
397
+ self._active_preview_id: int | None = None
398
+ self._next_preview_id = 1
399
+
400
+ # Rubber-band / selection for previews
401
+ self._preview_select_mode = False
402
+ self._rubber: QRubberBand | None = None
403
+ self._rubber_origin: QPoint | None = None
404
+
405
+ # ─────────────────────────────────────────────────────────
406
+ # UI construction
407
+ # ─────────────────────────────────────────────────────────
408
+ lyt = QVBoxLayout(self)
409
+
410
+ # Top row: drag-tab + Preview button
411
+ row = QHBoxLayout()
412
+ row.setContentsMargins(0, 0, 0, 0)
413
+ self._drag_tab = _DragTab(self)
414
+ row.addWidget(self._drag_tab, 0, Qt.AlignmentFlag.AlignLeft)
415
+
416
+ self._preview_btn = QToolButton(self)
417
+ self._preview_btn.setText("⟂") # crosshair glyph
418
+ self._preview_btn.setToolTip(self.tr("Create Preview: click, then drag on the image to define a preview rectangle."))
419
+ self._preview_btn.setCheckable(True)
420
+ self._preview_btn.clicked.connect(self._toggle_preview_select_mode)
421
+ row.addWidget(self._preview_btn, 0, Qt.AlignmentFlag.AlignLeft)
422
+ # — Undo / Redo just for this subwindow —
423
+ self._btn_undo = QToolButton(self)
424
+ self._btn_undo.setText("↶") # or use an icon
425
+ self._btn_undo.setToolTip(self.tr("Undo (this view)"))
426
+ self._btn_undo.setEnabled(False)
427
+ self._btn_undo.clicked.connect(self._on_local_undo)
428
+ row.addWidget(self._btn_undo, 0, Qt.AlignmentFlag.AlignLeft)
429
+
430
+ self._btn_redo = QToolButton(self)
431
+ self._btn_redo.setText("↷")
432
+ self._btn_redo.setToolTip(self.tr("Redo (this view)"))
433
+ self._btn_redo.setEnabled(False)
434
+ self._btn_redo.clicked.connect(self._on_local_redo)
435
+ row.addWidget(self._btn_redo, 0, Qt.AlignmentFlag.AlignLeft)
436
+
437
+ self._btn_replay_main = QToolButton(self)
438
+ self._btn_replay_main.setText("⟳") # pick any glyph you like
439
+ self._btn_replay_main.setToolTip(self.tr(
440
+ "Click: replay the last action on the base image.\n"
441
+ "Arrow: pick a specific past action to replay on the base image."
442
+ ))
443
+ self._btn_replay_main.setEnabled(False) # enabled only when preview + history
444
+
445
+ # Left-click = your existing 'replay last on base'
446
+ self._btn_replay_main.clicked.connect(self._on_replay_last_clicked)
447
+
448
+ # NEW: dropdown menu listing all replayable actions
449
+ self._replay_menu = QMenu(self)
450
+ self._btn_replay_main.setMenu(self._replay_menu)
451
+ self._btn_replay_main.setPopupMode(
452
+ QToolButton.ToolButtonPopupMode.MenuButtonPopup
453
+ )
454
+
455
+ row.addWidget(self._btn_replay_main, 0, Qt.AlignmentFlag.AlignLeft)
456
+
457
+
458
+ # ── NEW: WCS grid toggle ─────────────────────────────────────────
459
+ self._btn_wcs = QToolButton(self)
460
+ self._btn_wcs.setText("⌗")
461
+ self._btn_wcs.setToolTip(self.tr("Toggle WCS grid overlay (if WCS exists)"))
462
+ self._btn_wcs.setCheckable(True)
463
+
464
+ # Start OFF on every new view, regardless of WCS presence or past sessions
465
+ self._show_wcs_grid = False
466
+ self._btn_wcs.setChecked(False)
467
+
468
+ self._btn_wcs.toggled.connect(self._on_toggle_wcs_grid)
469
+ row.addWidget(self._btn_wcs, 0, Qt.AlignmentFlag.AlignLeft)
470
+ # ─────────────────────────────────────────────────────────────────
471
+
472
+ row.addStretch(1)
473
+ lyt.addLayout(row)
474
+
475
+ # QTabWidget that hosts "Full" (real viewer) + any Preview tabs (placeholder widgets)
476
+ self._tabs = QTabWidget(self)
477
+ self._tabs.setTabsClosable(True)
478
+ self._tabs.setDocumentMode(True)
479
+ self._tabs.setMovable(True)
480
+
481
+ # Build the default "Full" tab, which contains the ONE real viewer (scroll+label)
482
+ full_host = QWidget(self)
483
+ full_v = QVBoxLayout(full_host)
484
+ full_v.setContentsMargins(0, 0, 0, 0)
485
+
486
+ self.scroll = QScrollArea(full_host)
487
+ self.scroll.setWidgetResizable(False)
488
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
489
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
490
+ self.scroll.setWidget(self.label)
491
+ self.scroll.viewport().setMouseTracking(True)
492
+ self.label.setMouseTracking(True)
493
+ full_v.addWidget(self.scroll)
494
+
495
+ hbar = self.scroll.horizontalScrollBar()
496
+ vbar = self.scroll.verticalScrollBar()
497
+ for bar in (hbar, vbar):
498
+ bar.valueChanged.connect(self._on_scroll_changed)
499
+ bar.sliderMoved.connect(self._on_scroll_changed)
500
+ bar.actionTriggered.connect(self._on_scroll_changed)
501
+
502
+ # IMPORTANT: add the tab BEFORE connecting signals so currentChanged can't fire early
503
+ self._full_tab_idx = self._tabs.addTab(full_host, self.tr("Full"))
504
+ self._full_host = full_host
505
+ self._tabs.tabBar().setVisible(False) # hidden until a preview exists
506
+ lyt.addWidget(self._tabs)
507
+
508
+ # Now it’s safe to connect
509
+ self._tabs.tabCloseRequested.connect(self._on_tab_close_requested)
510
+ self._tabs.currentChanged.connect(self._on_tab_changed)
511
+ self._tabs.currentChanged.connect(lambda _=None: self._refresh_local_undo_buttons())
512
+
513
+ # DnD + event filters for the single viewer
514
+ self.setAcceptDrops(True)
515
+ self.scroll.viewport().installEventFilter(self)
516
+ self.label.installEventFilter(self)
517
+
518
+ # Context menu + shortcuts
519
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
520
+ self.customContextMenuRequested.connect(self._show_ctx_menu)
521
+ QShortcut(QKeySequence("F2"), self, activated=self._rename_view)
522
+ #QShortcut(QKeySequence("A"), self, activated=self.toggle_autostretch)
523
+ QShortcut(QKeySequence("Ctrl+Space"), self, activated=self.toggle_autostretch)
524
+ QShortcut(QKeySequence("Alt+Shift+A"), self, activated=self.toggle_autostretch)
525
+ QShortcut(QKeySequence("Ctrl+K"), self, activated=self.toggle_mask_overlay)
526
+
527
+ # Re-render when the document changes
528
+ self.document.changed.connect(lambda: self._render(rebuild=True))
529
+ self._render(rebuild=True)
530
+ QTimer.singleShot(0, self._maybe_announce_readout_help)
531
+ self._refresh_local_undo_buttons()
532
+ self._update_replay_button()
533
+
534
+ hbar = self.scroll.horizontalScrollBar()
535
+ vbar = self.scroll.verticalScrollBar()
536
+
537
+ for bar in (hbar, vbar):
538
+ bar.valueChanged.connect(self._schedule_emit_view_transform)
539
+ bar.sliderMoved.connect(lambda _=None: self._schedule_emit_view_transform())
540
+ bar.actionTriggered.connect(lambda _=None: self._schedule_emit_view_transform())
541
+
542
+ # Mask/title adornments
543
+ self._mask_dot_enabled = self._active_mask_array() is not None
544
+ self._active_title_prefix = False
545
+ self._rebuild_title()
546
+
547
+ # Track docs used by layer stack (if any)
548
+ self._watched_docs = set()
549
+ self._history_doc = None
550
+ self._install_history_watchers()
551
+
552
+ # ----- link drag payload -----
553
+ def _start_link_drag(self):
554
+ """
555
+ Alt + drag from ⧉: start a 'link these two views' drag.
556
+ """
557
+ payload = {
558
+ "source_view_id": id(self),
559
+ }
560
+ # identity hints (not strictly required, but nice to have)
561
+ try:
562
+ payload.update(self._drag_identity_fields())
563
+ except Exception:
564
+ pass
565
+
566
+ md = QMimeData()
567
+ md.setData(MIME_LINKVIEW, QByteArray(json.dumps(payload).encode("utf-8")))
568
+ drag = QDrag(self)
569
+ drag.setMimeData(md)
570
+ if self.label.pixmap():
571
+ drag.setPixmap(self.label.pixmap().scaled(
572
+ 64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
573
+ drag.setHotSpot(QPoint(16, 16))
574
+ drag.exec(Qt.DropAction.CopyAction)
575
+
576
+ # ----- link management -----
577
+ def link_to(self, other: "ImageSubWindow"):
578
+ if other is self or other in self._linked_views:
579
+ return
580
+
581
+ # Gather the full sets (including each endpoint)
582
+ a_group = set(self._linked_views) | {self}
583
+ b_group = set(other._linked_views) | {other}
584
+ merged = a_group | b_group
585
+
586
+ # Clear old badges so we can reapply cleanly
587
+ for v in merged:
588
+ try:
589
+ v._linked_views.discard(v) # no-op safety
590
+ except Exception:
591
+ pass
592
+
593
+ # Fully connect everyone to everyone
594
+ for v in merged:
595
+ v._linked_views.update(merged - {v})
596
+ try:
597
+ v._set_link_badge(True)
598
+ except Exception:
599
+ pass
600
+
601
+ # Snap everyone to the initiator’s transform immediately
602
+ try:
603
+ s, h, v = self._current_transform()
604
+ for peer in merged - {self}:
605
+ peer.set_view_transform(s, h, v, from_link=True)
606
+ except Exception:
607
+ pass
608
+
609
+
610
+ def unlink_from(self, other: "ImageSubWindow"):
611
+ if other in self._linked_views:
612
+ self._linked_views.discard(other)
613
+ other._linked_views.discard(self)
614
+ # clear badge if both are now free
615
+ if not self._linked_views:
616
+ self._set_link_badge(False)
617
+ if not other._linked_views:
618
+ other._set_link_badge(False)
619
+
620
+ def unlink_all(self):
621
+ peers = list(self._linked_views)
622
+ for p in peers:
623
+ self.unlink_from(p)
624
+
625
+ def _relay_to_linked(self, scale: float, h: int, v: int):
626
+ """
627
+ When this view pans/zooms, nudge all linked peers. Guarded to avoid loops.
628
+ """
629
+ for peer in list(self._linked_views):
630
+ try:
631
+ peer.set_view_transform(scale, h, v, from_link=True)
632
+ except Exception:
633
+ pass
634
+
635
+ def _set_link_badge(self, on: bool):
636
+ self._link_badge_on = bool(on)
637
+ self._rebuild_title()
638
+
639
+ def _on_scroll_changed(self, *_):
640
+ if self._suppress_link_emit:
641
+ return
642
+ # If we’re actively dragging, emit immediately for realtime follow
643
+ if self._dragging or self._pan_live:
644
+ self._emit_view_transform_now()
645
+ else:
646
+ self._schedule_emit_view_transform()
647
+
648
+ def _current_transform(self):
649
+ hbar = self.scroll.horizontalScrollBar()
650
+ vbar = self.scroll.verticalScrollBar()
651
+ return float(self.scale), int(hbar.value()), int(vbar.value())
652
+
653
+ def _emit_view_transform(self):
654
+ try:
655
+ h = int(self.scroll.horizontalScrollBar().value())
656
+ v = int(self.scroll.verticalScrollBar().value())
657
+ except Exception:
658
+ h = v = 0
659
+ try:
660
+ self.viewTransformChanged.emit(float(self.scale), h, v)
661
+ except Exception:
662
+ pass
663
+
664
+ def _schedule_emit_view_transform(self):
665
+ if self._suppress_link_emit:
666
+ return
667
+ # If we’re in a live pan, don’t debounce—emit now.
668
+ if self._dragging or self._pan_live:
669
+ self._emit_view_transform_now()
670
+ else:
671
+ self._link_emit_timer.start()
672
+
673
+ def _emit_view_transform_now(self):
674
+ if self._suppress_link_emit:
675
+ return
676
+ h = self.scroll.horizontalScrollBar().value()
677
+ v = self.scroll.verticalScrollBar().value()
678
+ try:
679
+ self.viewTransformChanged.emit(float(self.scale), int(h), int(v))
680
+ except Exception:
681
+ pass
682
+
683
+ #------ Replay helpers------
684
+ #------ Replay helpers------
685
+ def _update_replay_button(self):
686
+ """
687
+ Update the 'Replay on main image' button:
688
+
689
+ - Enabled only when a Preview/ROI is active.
690
+ - Populates the dropdown menu with all headless-history entries
691
+ from the main window (newest first).
692
+ """
693
+ btn = getattr(self, "_btn_replay_main", None)
694
+ if not btn:
695
+ return
696
+
697
+ # Do we have an active preview in this view?
698
+ try:
699
+ has_preview = self.has_active_preview()
700
+ except Exception:
701
+ has_preview = False
702
+
703
+ mw = self._find_main_window()
704
+ menu = getattr(self, "_replay_menu", None)
705
+
706
+ history = []
707
+ has_history = False
708
+
709
+ # Pull history from main window if available
710
+ if mw is not None and hasattr(mw, "get_headless_history"):
711
+ try:
712
+ history = mw.get_headless_history() or []
713
+ has_history = bool(history)
714
+ except Exception:
715
+ history = []
716
+ has_history = False
717
+
718
+ # Rebuild the dropdown menu
719
+ if menu is not None:
720
+ menu.clear()
721
+ if has_history:
722
+ # We want newest actions at the *top* of the menu
723
+ for idx_from_end, entry in enumerate(reversed(history)):
724
+ real_index = len(history) - 1 - idx_from_end # index into original list
725
+
726
+ cid = entry.get("command_id", "") or ""
727
+ desc = entry.get("description") or cid or f"#{real_index+1}"
728
+
729
+ act = menu.addAction(desc)
730
+ if cid and cid != desc:
731
+ act.setToolTip(cid)
732
+
733
+ # Capture the index in a default arg so each action gets its own index
734
+ act.triggered.connect(
735
+ lambda _chk=False, i=real_index: self._replay_history_index(i)
736
+ )
737
+
738
+ # Also allow left-click "last action" when main window still has a last payload
739
+ has_last = bool(mw and getattr(mw, "_last_headless_command", None))
740
+
741
+ enabled = bool(has_preview and (has_history or has_last))
742
+ btn.setEnabled(enabled)
743
+
744
+ # DEBUG:
745
+ try:
746
+ print(
747
+ f"[Replay] _update_replay_button: view id={id(self)} "
748
+ f"enabled={enabled}, has_preview={has_preview}, "
749
+ f"history_len={len(history)}"
750
+ )
751
+ except Exception:
752
+ pass
753
+
754
+ def _replay_history_index(self, index: int):
755
+ """
756
+ Called when the user selects an entry from the replay dropdown.
757
+
758
+ We forward to MainWindow.replay_headless_history_entry_on_base(index, target_sw),
759
+ which reuses the big replay_last_action_on_base() switchboard.
760
+ """
761
+ mw = self._find_main_window()
762
+ if mw is None or not hasattr(mw, "replay_headless_history_entry_on_base"):
763
+ try:
764
+ print("[Replay] _replay_history_index: main window or handler missing")
765
+ except Exception:
766
+ pass
767
+ return
768
+
769
+ target_sw = self._mdi_subwindow()
770
+
771
+ try:
772
+ mw.replay_headless_history_entry_on_base(index, target_sw=target_sw)
773
+ try:
774
+ print(
775
+ f"[Replay] _replay_history_index: index={index}, "
776
+ f"view id={id(self)}, target_sw={id(target_sw) if target_sw else None}"
777
+ )
778
+ except Exception:
779
+ pass
780
+ except Exception as e:
781
+ try:
782
+ print(f"[Replay] _replay_history_index failed: {e}")
783
+ except Exception:
784
+ pass
785
+
786
+
787
+ def _on_replay_last_clicked(self):
788
+ """
789
+ User clicked the ⟳ button *main area* (not the arrow).
790
+
791
+ This still does the old behavior:
792
+ - Emit replayOnBaseRequested(view)
793
+ - Main window then replays the *last* action on the base doc
794
+ for this subwindow (via replay_last_action_on_base).
795
+ """
796
+ # DEBUG: log that the button actually fired
797
+ try:
798
+ roi = None
799
+ if hasattr(self, "has_active_preview") and self.has_active_preview():
800
+ try:
801
+ roi = self.current_preview_roi()
802
+ except Exception:
803
+ roi = None
804
+ print(
805
+ f"[Replay] Button clicked in view id={id(self)}, "
806
+ f"has_active_preview={self.has_active_preview() if hasattr(self, 'has_active_preview') else 'n/a'}, "
807
+ f"roi={roi}"
808
+ )
809
+ except Exception:
810
+ pass
811
+
812
+ # Emit self so the main window can locate our QMdiSubWindow wrapper.
813
+ try:
814
+ print(f"[Replay] Emitting replayOnBaseRequested from view id={id(self)}")
815
+ except Exception:
816
+ pass
817
+ self.replayOnBaseRequested.emit(self)
818
+
819
+
820
+
821
+ def _on_pan_or_zoom_changed(self, *_):
822
+ # Debounce lightly if you want; for now, just emit
823
+ self._emit_view_transform()
824
+
825
+ def set_view_transform(self, scale, hval, vval, from_link=False):
826
+ # Avoid storms while we mutate scrollbars/scale
827
+ self._suppress_link_emit = True
828
+ try:
829
+ scale = float(max(self._min_scale, min(scale, self._max_scale)))
830
+ if abs(scale - self.scale) > 1e-9:
831
+ self.scale = scale
832
+ self._render(rebuild=False)
833
+
834
+ hbar = self.scroll.horizontalScrollBar()
835
+ vbar = self.scroll.verticalScrollBar()
836
+ hv = int(hval); vv = int(vval)
837
+ if hv != hbar.value():
838
+ hbar.setValue(hv)
839
+ if vv != vbar.value():
840
+ vbar.setValue(vv)
841
+ finally:
842
+ self._suppress_link_emit = False
843
+
844
+ # IMPORTANT: if this came from a linked peer, do NOT broadcast again.
845
+ if not from_link:
846
+ self._schedule_emit_view_transform()
847
+
848
+ def _on_toggle_wcs_grid(self, on: bool):
849
+ self._show_wcs_grid = bool(on)
850
+ QSettings().setValue("display/show_wcs_grid", self._show_wcs_grid)
851
+ self._render(rebuild=False) # repaint current frame
852
+
853
+
854
+
855
+ def _install_history_watchers(self):
856
+ # disconnect old history doc
857
+ hd = getattr(self, "_history_doc", None)
858
+ if hd is not None and hasattr(hd, "changed"):
859
+ try:
860
+ hd.changed.disconnect(self._on_history_doc_changed)
861
+ except Exception:
862
+ pass
863
+ # in case older builds were wired directly:
864
+ try:
865
+ hd.changed.disconnect(self._refresh_local_undo_buttons)
866
+ except Exception:
867
+ pass
868
+
869
+ # resolve new history doc (ROI when on Preview tab, else base)
870
+ new_hd = self._resolve_history_doc()
871
+ self._history_doc = new_hd
872
+
873
+ # connect new
874
+ if new_hd is not None and hasattr(new_hd, "changed"):
875
+ try:
876
+ new_hd.changed.connect(self._on_history_doc_changed)
877
+ except Exception:
878
+ pass
879
+
880
+ # make the buttons correct right now
881
+ self._refresh_local_undo_buttons()
882
+
883
+ def _drag_identity_fields(self):
884
+ """
885
+ Returns a dict with identity hints for DnD:
886
+ doc_uid (preferred), base_doc_uid (parent/full), and file_path.
887
+ Falls back gracefully if fields are missing.
888
+ """
889
+ doc = getattr(self, "document", None)
890
+ base = getattr(self, "base_document", None) or doc
891
+
892
+ # If DocManager maps preview/ROI views, prefer the true backing doc as base
893
+ dm = getattr(self, "_docman", None)
894
+ try:
895
+ if dm and hasattr(dm, "get_document_for_view"):
896
+ back = dm.get_document_for_view(self)
897
+ if back is not None:
898
+ base = back
899
+ except Exception:
900
+ pass
901
+
902
+ meta = (getattr(doc, "metadata", None) or {})
903
+ base_meta = (getattr(base, "metadata", None) or {})
904
+
905
+ return {
906
+ "doc_uid": getattr(doc, "uid", None),
907
+ "base_doc_uid": getattr(base, "uid", None),
908
+ "file_path": meta.get("file_path") or base_meta.get("file_path") or "",
909
+ }
910
+
911
+
912
+ def _on_local_undo(self):
913
+ doc = self._resolve_history_doc()
914
+ if not doc or not hasattr(doc, "undo"):
915
+ return
916
+ try:
917
+ doc.undo()
918
+ # most ImageDocument implementations emit changed; belt-and-suspenders:
919
+ if hasattr(doc, "changed"): doc.changed.emit()
920
+ except Exception:
921
+ pass
922
+ # repaint and refresh our buttons
923
+ self._render(rebuild=True)
924
+ self._refresh_local_undo_buttons()
925
+
926
+ def _on_local_redo(self):
927
+ doc = self._resolve_history_doc()
928
+ if not doc or not hasattr(doc, "redo"):
929
+ return
930
+ try:
931
+ doc.redo()
932
+ if hasattr(doc, "changed"): doc.changed.emit()
933
+ except Exception:
934
+ pass
935
+ self._render(rebuild=True)
936
+ self._refresh_local_undo_buttons()
937
+
938
+
939
+ def refresh_preview_roi(self, roi_tuple=None):
940
+ """
941
+ Rebuild the active preview pixmap from the parent document’s data.
942
+ If roi_tuple is provided, it's the updated region (x,y,w,h).
943
+ """
944
+ try:
945
+ if not (hasattr(self, "has_active_preview") and self.has_active_preview()):
946
+ return
947
+
948
+ # Optional: sanity check that roi matches the current preview
949
+ if roi_tuple is not None:
950
+ cur = self.current_preview_roi()
951
+ if not (cur and tuple(map(int, cur)) == tuple(map(int, roi_tuple))):
952
+ return # different preview; no refresh needed
953
+
954
+ # Your own method that (re)generates the preview pixmap from the doc
955
+ if hasattr(self, "rebuild_preview_pixmap") and callable(self.rebuild_preview_pixmap):
956
+ self.rebuild_preview_pixmap()
957
+ elif hasattr(self, "_update_preview_layer") and callable(self._update_preview_layer):
958
+ self._update_preview_layer()
959
+ else:
960
+ # Fallback: repaint
961
+ self.update()
962
+ except Exception:
963
+ pass
964
+
965
+ def refresh_full(self):
966
+ """Full-image redraw hook for non-ROI updates."""
967
+ try:
968
+ if hasattr(self, "rebuild_image_pixmap") and callable(self.rebuild_image_pixmap):
969
+ self.rebuild_image_pixmap()
970
+ else:
971
+ self.update()
972
+ except Exception:
973
+ pass
974
+
975
+ def refresh_preview_region(self, roi):
976
+ """
977
+ roi: (x,y,w,h) in FULL image coords. Rebuild the active Preview tab’s pixmap
978
+ from self.document.image[y:y+h, x:x+w].
979
+ """
980
+ if not (hasattr(self, "has_active_preview") and self.has_active_preview()):
981
+ # No preview active → fall back to full refresh
982
+ if hasattr(self, "refresh_from_document"):
983
+ self.refresh_from_document()
984
+ else:
985
+ self.update()
986
+ return
987
+
988
+ try:
989
+ x, y, w, h = map(int, roi)
990
+ arr = self.document.image[y:y+h, x:x+w]
991
+ # Whatever your existing path is to update the preview tab from an ndarray:
992
+ # e.g., self._set_preview_from_array(arr) or self._update_preview_pixmap(arr)
993
+ if hasattr(self, "_set_preview_from_array"):
994
+ self._set_preview_from_array(arr)
995
+ elif hasattr(self, "update_preview_from_array"):
996
+ self.update_preview_from_array(arr)
997
+ else:
998
+ # Fallback: full refresh if you don’t expose a thin setter
999
+ if hasattr(self, "rebuild_active_preview"):
1000
+ self.rebuild_active_preview()
1001
+ elif hasattr(self, "refresh_from_document"):
1002
+ self.refresh_from_document()
1003
+ self.update()
1004
+ except Exception:
1005
+ # Safe fallback
1006
+ if hasattr(self, "rebuild_active_preview"):
1007
+ self.rebuild_active_preview()
1008
+ elif hasattr(self, "refresh_from_document"):
1009
+ self.refresh_from_document()
1010
+ else:
1011
+ self.update()
1012
+
1013
+
1014
+ def _ensure_tabs(self):
1015
+ if self._tabs:
1016
+ return
1017
+ self._tabs = QTabWidget(self)
1018
+ self._tabs.setTabsClosable(True)
1019
+ self._tabs.tabCloseRequested.connect(self._on_tab_close_requested)
1020
+ self._tabs.currentChanged.connect(self._on_tab_changed)
1021
+ self._tabs.setDocumentMode(True)
1022
+ self._tabs.setMovable(True)
1023
+
1024
+ # Build the default "Full" tab: it contains your scroll+label
1025
+ full_host = QWidget(self)
1026
+ v = QVBoxLayout(full_host)
1027
+ v.setContentsMargins(QMargins(0,0,0,0))
1028
+ # Reuse your existing scroll/label as the content of the "Full" tab
1029
+ self.scroll = QScrollArea(full_host)
1030
+ self.scroll.setWidgetResizable(False)
1031
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
1032
+ self.scroll.setWidget(self.label)
1033
+ v.addWidget(self.scroll)
1034
+ self._full_tab_idx = self._tabs.addTab(full_host, self.tr("Full"))
1035
+ self._full_host = full_host
1036
+ self._tabs.tabBar().setVisible(False) # hidden until a first preview exists
1037
+
1038
+ def _on_tab_close_requested(self, idx: int):
1039
+ # Prevent closing "Full"
1040
+ if idx == self._full_tab_idx:
1041
+ return
1042
+ wid = self._tabs.widget(idx)
1043
+ prev_id = getattr(wid, "_preview_id", None)
1044
+
1045
+ # Remove model entry
1046
+ self._previews = [p for p in self._previews if p["id"] != prev_id]
1047
+ # If you closed the active one, fall back to full
1048
+ if self._active_preview_id == prev_id:
1049
+ self._active_source_kind = "full"
1050
+ self._active_preview_id = None
1051
+ self._render(True)
1052
+
1053
+ self._tabs.removeTab(idx)
1054
+ wid.deleteLater()
1055
+
1056
+ # Hide tabs if no more previews
1057
+ if not self._previews:
1058
+ self._tabs.tabBar().setVisible(False)
1059
+
1060
+ self._update_replay_button()
1061
+
1062
+ def _on_tab_changed(self, idx: int):
1063
+ if not hasattr(self, "_full_tab_idx"):
1064
+ return
1065
+ if idx == self._full_tab_idx:
1066
+ self._active_source_kind = "full"
1067
+ self._active_preview_id = None
1068
+ host = getattr(self, "_full_host", None) or self._tabs.widget(idx) # ← safe
1069
+ else:
1070
+ wid = self._tabs.widget(idx)
1071
+ self._active_source_kind = "preview"
1072
+ self._active_preview_id = getattr(wid, "_preview_id", None)
1073
+ host = wid
1074
+
1075
+ if host is not None:
1076
+ self._move_view_into(host)
1077
+ self._install_history_watchers()
1078
+ self._render(True)
1079
+ self._refresh_local_undo_buttons()
1080
+ self._update_replay_button()
1081
+ self._emit_view_transform()
1082
+ mw = self._find_main_window()
1083
+ if mw is not None and getattr(mw, "_auto_fit_on_resize", False):
1084
+ try:
1085
+ mw._zoom_active_fit()
1086
+ except Exception:
1087
+ pass
1088
+
1089
+ def _toggle_preview_select_mode(self, on: bool):
1090
+ self._preview_select_mode = bool(on)
1091
+ self._set_preview_cursor(self._preview_select_mode)
1092
+ if self._preview_select_mode:
1093
+ mw = self._find_main_window()
1094
+ if mw and hasattr(mw, "statusBar"):
1095
+ mw.statusBar().showMessage(self.tr("Preview mode: drag a rectangle on the image to create a preview."), 6000)
1096
+ else:
1097
+ self._cancel_rubber()
1098
+
1099
+ def _cancel_rubber(self):
1100
+ if self._rubber is not None:
1101
+ self._rubber.hide()
1102
+ self._rubber.deleteLater()
1103
+ self._rubber = None
1104
+ self._rubber_origin = None
1105
+ self._preview_select_mode = False
1106
+ self._set_preview_cursor(False)
1107
+ if self._preview_btn.isChecked():
1108
+ self._preview_btn.setChecked(False)
1109
+
1110
+ def _current_tab_host(self):
1111
+ # returns the QWidget inside the current tab
1112
+ return self._tabs.widget(self._tabs.currentIndex())
1113
+
1114
+ def _move_view_into(self, host_widget: QWidget):
1115
+ """Reparent the single viewer (scroll+label) into host_widget's layout."""
1116
+ if self.scroll.parent() is host_widget:
1117
+ return
1118
+ # take it out of the old parent layout
1119
+ try:
1120
+ old_layout = self.scroll.parentWidget().layout()
1121
+ if old_layout:
1122
+ old_layout.removeWidget(self.scroll)
1123
+ except Exception:
1124
+ pass
1125
+
1126
+ # ensure host has a VBox layout
1127
+ lay = host_widget.layout()
1128
+ if lay is None:
1129
+ from PyQt6.QtWidgets import QVBoxLayout
1130
+ lay = QVBoxLayout(host_widget)
1131
+ lay.setContentsMargins(0, 0, 0, 0)
1132
+
1133
+ # insert viewer; kill any placeholder child labels if present
1134
+ try:
1135
+ kids = host_widget.findChildren(QLabel, options=Qt.FindChildOption.FindDirectChildrenOnly)
1136
+ except Exception:
1137
+ kids = host_widget.findChildren(QLabel) # recursive fallback
1138
+ for ch in list(kids):
1139
+ if ch is not self.label:
1140
+ ch.deleteLater()
1141
+
1142
+ self.scroll.setParent(host_widget)
1143
+ lay.addWidget(self.scroll)
1144
+ self.scroll.show()
1145
+
1146
+ def _set_preview_cursor(self, active: bool):
1147
+ cur = Qt.CursorShape.CrossCursor if active else Qt.CursorShape.ArrowCursor
1148
+ for w in (self, getattr(self, "scroll", None) and self.scroll.viewport(), getattr(self, "label", None)):
1149
+ if not w:
1150
+ continue
1151
+ try:
1152
+ w.unsetCursor() # clear any prior override
1153
+ w.setCursor(cur) # then set desired cursor
1154
+ except Exception:
1155
+ pass
1156
+
1157
+
1158
+ def _maybe_announce_readout_help(self):
1159
+ """Show the readout hint only once automatically."""
1160
+ if self._readout_hint_shown:
1161
+ return
1162
+ self._announce_readout_help()
1163
+ self._readout_hint_shown = True
1164
+
1165
+ def _announce_readout_help(self):
1166
+ mw = self._find_main_window()
1167
+ if mw and hasattr(mw, "statusBar"):
1168
+ sb = mw.statusBar()
1169
+ if sb:
1170
+ sb.showMessage(self.tr("Press Space + Click/Drag to probe pixels (WCS shown if available)"), 8000)
1171
+
1172
+
1173
+
1174
+ def apply_layer_stack(self, layers):
1175
+ """
1176
+ Rebuild the display override from base document + given layer stack.
1177
+ Does not mutate the underlying document.image.
1178
+ """
1179
+ try:
1180
+ base = self.document.image
1181
+ if layers:
1182
+ comp = composite_stack(base, layers)
1183
+ self._display_override = comp
1184
+ else:
1185
+ self._display_override = None
1186
+ self.layers_changed.emit()
1187
+ self._render(rebuild=True)
1188
+ except Exception as e:
1189
+ print("[ImageSubWindow] apply_layer_stack error:", e)
1190
+
1191
+ # --- add to ImageSubWindow ---
1192
+ def _collect_layer_docs(self):
1193
+ docs = set()
1194
+ for L in getattr(self, "_layers", []):
1195
+ d = getattr(L, "src_doc", None)
1196
+ if d is not None:
1197
+ docs.add(d)
1198
+ md = getattr(L, "mask_doc", None)
1199
+ if md is not None:
1200
+ docs.add(md)
1201
+ return docs
1202
+
1203
+ def keyPressEvent(self, ev):
1204
+ if ev.key() == Qt.Key.Key_Space:
1205
+ # only the first time we enter probe mode
1206
+ if not self._space_down and not self._readout_hint_shown:
1207
+ self._announce_readout_help()
1208
+ self._readout_hint_shown = True
1209
+ self._space_down = True
1210
+ ev.accept()
1211
+ return
1212
+ super().keyPressEvent(ev)
1213
+
1214
+
1215
+
1216
+ def keyReleaseEvent(self, ev):
1217
+ if ev.key() == Qt.Key.Key_Space:
1218
+ self._space_down = False
1219
+ # DO NOT stop _readout_dragging here – mouse release will do that
1220
+ ev.accept()
1221
+ return
1222
+ super().keyReleaseEvent(ev)
1223
+
1224
+
1225
+
1226
+ def _sample_image_at_viewport_pos(self, vp_pos: QPoint):
1227
+ """
1228
+ vp_pos: position in viewport coords (the visible part of the scroll area).
1229
+ Returns (x_img_int, y_img_int, sample_dict) or None if OOB.
1230
+ sample_dict is always raw float(s), never normalized.
1231
+ """
1232
+ if self.document is None or self.document.image is None:
1233
+ return None
1234
+
1235
+ arr = np.asarray(self.document.image)
1236
+
1237
+ # detect shape
1238
+ if arr.ndim == 2:
1239
+ h, w = arr.shape
1240
+ channels = 1
1241
+ elif arr.ndim == 3:
1242
+ h, w, channels = arr.shape[:3]
1243
+ else:
1244
+ return None # unsupported shape
1245
+
1246
+ # current scroll offsets
1247
+ hbar = self.scroll.horizontalScrollBar()
1248
+ vbar = self.scroll.verticalScrollBar()
1249
+ x_label = hbar.value() + vp_pos.x()
1250
+ y_label = vbar.value() + vp_pos.y()
1251
+
1252
+ scale = max(self.scale, 1e-12)
1253
+ x_img = x_label / scale
1254
+ y_img = y_label / scale
1255
+
1256
+ xi = int(round(x_img))
1257
+ yi = int(round(y_img))
1258
+
1259
+ if xi < 0 or yi < 0 or xi >= w or yi >= h:
1260
+ return None
1261
+
1262
+ # ---- mono cases ----
1263
+ if arr.ndim == 2 or channels == 1:
1264
+ # pure mono or (H, W, 1)
1265
+ if arr.ndim == 2:
1266
+ val = float(arr[yi, xi])
1267
+ else:
1268
+ val = float(arr[yi, xi, 0])
1269
+ sample = {"mono": val}
1270
+ return (xi, yi, sample)
1271
+
1272
+ # ---- color / 3+ channels ----
1273
+ pix = arr[yi, xi]
1274
+
1275
+ # make robust if pix is 1-D
1276
+ # expect at least 3 numbers, fallback to repeating R
1277
+ r = float(pix[0])
1278
+ g = float(pix[1]) if channels > 1 else r
1279
+ b = float(pix[2]) if channels > 2 else r
1280
+
1281
+ sample = {"r": r, "g": g, "b": b}
1282
+ return (xi, yi, sample)
1283
+
1284
+
1285
+
1286
+ def sizeHint(self) -> QSize:
1287
+ lbl = getattr(self, "image_label", None) or getattr(self, "label", None)
1288
+ sa = getattr(self, "scroll_area", None) or self.findChild(QScrollArea)
1289
+ if lbl and hasattr(lbl, "pixmap") and lbl.pixmap() and not lbl.pixmap().isNull():
1290
+ pm = lbl.pixmap()
1291
+ # logical pixels (HiDPI-safe)
1292
+ dpr = pm.devicePixelRatioF() if hasattr(pm, "devicePixelRatioF") else 1.0
1293
+ pm_w = int(math.ceil(pm.width() / dpr))
1294
+ pm_h = int(math.ceil(pm.height() / dpr))
1295
+
1296
+ # label margins
1297
+ lm = lbl.contentsMargins()
1298
+ w = pm_w + lm.left() + lm.right()
1299
+ h = pm_h + lm.top() + lm.bottom()
1300
+
1301
+ # scrollarea chrome (frame + reserve bar thickness)
1302
+ if sa:
1303
+ fw = sa.frameWidth()
1304
+ w += fw * 2 + sa.verticalScrollBar().sizeHint().width()
1305
+ h += fw * 2 + sa.horizontalScrollBar().sizeHint().height()
1306
+
1307
+ # this widget’s margins
1308
+ m = self.contentsMargins()
1309
+ w += m.left() + m.right() + 2
1310
+ h += m.top() + m.bottom() + 20
1311
+
1312
+ # tiny safety pad so bars never appear from rounding
1313
+ return QSize(w + 2, h + 8)
1314
+
1315
+ return super().sizeHint()
1316
+
1317
+ def _on_layer_source_changed(self):
1318
+ # Any source/mask doc changed → recomposite current stack
1319
+ try:
1320
+ self.apply_layer_stack(self._layers)
1321
+ except Exception as e:
1322
+ print("[ImageSubWindow] _on_layer_source_changed error:", e)
1323
+
1324
+ def _reinstall_layer_watchers(self):
1325
+ # Disconnect old
1326
+ for d in list(self._watched_docs):
1327
+ try:
1328
+ d.changed.disconnect(self._on_layer_source_changed)
1329
+ except Exception:
1330
+ pass
1331
+ # Connect new
1332
+ newdocs = self._collect_layer_docs()
1333
+ for d in newdocs:
1334
+ try:
1335
+ d.changed.connect(self._on_layer_source_changed)
1336
+ except Exception:
1337
+ pass
1338
+ self._watched_docs = newdocs
1339
+
1340
+
1341
+ def toggle_mask_overlay(self):
1342
+ self.show_mask_overlay = not self.show_mask_overlay
1343
+ self._render(rebuild=True)
1344
+
1345
+ def _rebuild_title(self, *, base: str | None = None):
1346
+ sub = self._mdi_subwindow()
1347
+ if not sub: return
1348
+ if base is None:
1349
+ base = self._effective_title() or self.tr("Untitled")
1350
+
1351
+ # ✅ strip any carried-over glyphs (🔗, ■, “Active View: ”) from overrides/doc names
1352
+ core, _ = self._strip_decorations(base)
1353
+
1354
+ title = core
1355
+ if getattr(self, "_link_badge_on", False):
1356
+ title = f"{LINK_PREFIX}{title}"
1357
+ if self._mask_dot_enabled:
1358
+ title = f"{MASK_GLYPH} {title}"
1359
+
1360
+ if title != sub.windowTitle():
1361
+ sub.setWindowTitle(title)
1362
+ sub.setToolTip(title)
1363
+ if title != self._last_title_for_emit:
1364
+ self._last_title_for_emit = title
1365
+ try: self.viewTitleChanged.emit(self, title)
1366
+ except Exception as e:
1367
+ import logging
1368
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1369
+
1370
+
1371
+ def _strip_decorations(self, title: str) -> tuple[str, bool]:
1372
+ had = False
1373
+ # loop to remove multiple stacked badges, in any order
1374
+ while True:
1375
+ changed = False
1376
+
1377
+ # A) explicit multi-char prefixes
1378
+ for pref in DECORATION_PREFIXES:
1379
+ if title.startswith(pref):
1380
+ title = title[len(pref):]
1381
+ had = changed = True
1382
+
1383
+ # B) generic 1-glyph + space (covers any stray glyph in GLYPHS)
1384
+ if len(title) >= 2 and title[1] == " " and title[0] in GLYPHS:
1385
+ title = title[2:]
1386
+ had = changed = True
1387
+
1388
+ if not changed:
1389
+ break
1390
+
1391
+ return title, had
1392
+
1393
+
1394
+ def set_active_highlight(self, on: bool):
1395
+ self._is_active_flag = bool(on)
1396
+ return
1397
+ sub = self._mdi_subwindow()
1398
+ if not sub:
1399
+ return
1400
+
1401
+ core, had_glyph = self._strip_decorations(sub.windowTitle())
1402
+
1403
+ if on and not getattr(self, "_suppress_active_once", False):
1404
+ core = ACTIVE_PREFIX + core
1405
+ self._suppress_active_once = False
1406
+
1407
+ # recompose: glyph (from flag), then active prefix, then base/core
1408
+ if getattr(self, "_mask_dot_enabled", False):
1409
+ core = "■ " + core
1410
+ #sub.setWindowTitle(core)
1411
+ sub.setToolTip(core)
1412
+
1413
+ def _set_mask_highlight(self, on: bool):
1414
+ self._mask_dot_enabled = bool(on)
1415
+ self._rebuild_title()
1416
+
1417
+ def _sync_host_title(self):
1418
+ # document renamed → rebuild from flags + new base
1419
+ self._rebuild_title()
1420
+
1421
+
1422
+
1423
+ def base_doc_title(self) -> str:
1424
+ """The clean, base title (document display name), no prefixes/suffixes."""
1425
+ return self.document.display_name() or self.tr("Untitled")
1426
+
1427
+ def _active_mask_array(self):
1428
+ """Return the active mask ndarray (H,W) or None."""
1429
+ doc = getattr(self, "document", None)
1430
+ if not doc:
1431
+ return None
1432
+ mid = getattr(doc, "active_mask_id", None)
1433
+ if not mid:
1434
+ return None
1435
+ masks = getattr(doc, "masks", {}) or {}
1436
+ layer = masks.get(mid)
1437
+ if layer is None:
1438
+ return None
1439
+ data = getattr(layer, "data", None)
1440
+ if data is None:
1441
+ return None
1442
+ import numpy as np
1443
+ a = np.asarray(data)
1444
+ if a.ndim == 3 and a.shape[2] == 1:
1445
+ a = a[..., 0]
1446
+ if a.ndim != 2:
1447
+ return None
1448
+ # ensure 0..1 float
1449
+ a = a.astype(np.float32, copy=False)
1450
+ a = np.clip(a, 0.0, 1.0)
1451
+ return a
1452
+
1453
+ def refresh_mask_overlay(self):
1454
+ """Recompute the source buffer (incl. red mask tint) and repaint."""
1455
+ self._render(rebuild=True)
1456
+
1457
+ def _apply_subwindow_style(self):
1458
+ """No-op shim retained for backward compatibility."""
1459
+ pass
1460
+
1461
+ def _on_doc_mask_changed(self):
1462
+ """Doc changed → refresh highlight and overlay if needed."""
1463
+ has_mask = self._active_mask_array() is not None
1464
+ self._set_mask_highlight(has_mask)
1465
+ if self.show_mask_overlay and has_mask:
1466
+ self._render(rebuild=True)
1467
+ elif self.show_mask_overlay and not has_mask:
1468
+ # overlay was on but mask went away → just redraw to clear
1469
+ self._render(rebuild=True)
1470
+
1471
+
1472
+ # ---------- public API ----------
1473
+ def set_autostretch(self, on: bool):
1474
+ on = bool(on)
1475
+ if on == getattr(self, "autostretch_enabled", False):
1476
+ # still rebuild so linked profile changes can reflect immediately if desired
1477
+ pass
1478
+ self.autostretch_enabled = on
1479
+ try:
1480
+ self.autostretchChanged.emit(on)
1481
+ except Exception:
1482
+ pass
1483
+ # keep your newer fast-path behavior
1484
+ self._recompute_autostretch_and_update()
1485
+
1486
+ def toggle_autostretch(self):
1487
+ self.set_autostretch(not self.autostretch_enabled)
1488
+
1489
+ def set_autostretch_target(self, target: float):
1490
+ self.autostretch_target = float(target)
1491
+ if self.autostretch_enabled:
1492
+ self._render(rebuild=True)
1493
+
1494
+ def set_autostretch_sigma(self, sigma: float):
1495
+ self.autostretch_sigma = float(sigma)
1496
+ if self.autostretch_enabled:
1497
+ self._render(rebuild=True)
1498
+
1499
+ def set_autostretch_profile(self, profile: str):
1500
+ """'normal' => target=0.25, sigma=3 ; 'hard' => target=0.5, sigma=1"""
1501
+ p = (profile or "").lower()
1502
+ if p not in ("normal", "hard"):
1503
+ p = "normal"
1504
+ if p == self.autostretch_profile:
1505
+ return
1506
+ if p == "hard":
1507
+ self.autostretch_target = 0.5
1508
+ self.autostretch_sigma = 2
1509
+ else:
1510
+ self.autostretch_target = 0.3
1511
+ self.autostretch_sigma = 5
1512
+ self.autostretch_profile = p
1513
+ if self.autostretch_enabled:
1514
+ self._render(rebuild=True)
1515
+
1516
+ def is_hard_autostretch(self) -> bool:
1517
+ return self.autostretch_profile == "hard"
1518
+
1519
+ def _mdi_subwindow(self) -> QMdiSubWindow | None:
1520
+ w = self.parent()
1521
+ while w is not None and not isinstance(w, QMdiSubWindow):
1522
+ w = w.parent()
1523
+ return w
1524
+
1525
+ def _effective_title(self) -> str:
1526
+ # Prefer a per-view override; otherwise doc display name
1527
+ return self._view_title_override or self.document.display_name()
1528
+
1529
+ def _show_ctx_menu(self, pos):
1530
+ menu = QMenu(self)
1531
+ a_view = menu.addAction(self.tr("Rename View… (F2)"))
1532
+ a_doc = menu.addAction(self.tr("Rename Document…"))
1533
+ menu.addSeparator()
1534
+ a_min = menu.addAction(self.tr("Send to Shelf"))
1535
+ a_clear = menu.addAction(self.tr("Clear View Name (use doc name)"))
1536
+ menu.addSeparator()
1537
+ a_unlink = menu.addAction(self.tr("Unlink from Linked Views")) # ← NEW
1538
+ menu.addSeparator()
1539
+ a_help = menu.addAction(self.tr("Show pixel/WCS readout hint"))
1540
+ menu.addSeparator()
1541
+ a_prev = menu.addAction(self.tr("Create Preview (drag rectangle)"))
1542
+
1543
+ act = menu.exec(self.mapToGlobal(pos))
1544
+
1545
+ if act == a_view:
1546
+ self._rename_view()
1547
+ elif act == a_doc:
1548
+ self._rename_document()
1549
+ elif act == a_min:
1550
+ self._send_to_shelf()
1551
+ elif act == a_clear:
1552
+ self._view_title_override = None
1553
+ self._sync_host_title()
1554
+ elif act == a_unlink:
1555
+ self.unlink_all()
1556
+ elif act == a_help:
1557
+ self._announce_readout_help()
1558
+ elif act == a_prev:
1559
+ self._preview_btn.setChecked(True)
1560
+ self._toggle_preview_select_mode(True)
1561
+
1562
+
1563
+
1564
+ def _send_to_shelf(self):
1565
+ sub = self._mdi_subwindow()
1566
+ mw = self._find_main_window()
1567
+ if sub and mw and hasattr(mw, "window_shelf"):
1568
+ sub.hide()
1569
+ mw.window_shelf.add_entry(sub)
1570
+
1571
+
1572
+ def _rename_view(self):
1573
+ current = self._view_title_override or self.document.display_name()
1574
+ new, ok = QInputDialog.getText(self, self.tr("Rename View"), self.tr("New view name:"), text=current)
1575
+ if ok and new.strip():
1576
+ self._view_title_override = new.strip()
1577
+ self._sync_host_title() # calls _rebuild_title → emits viewTitleChanged
1578
+
1579
+ # optional: directly ping layers dock (defensive)
1580
+ mw = self._find_main_window()
1581
+ if mw and hasattr(mw, "layers_dock") and mw.layers_dock:
1582
+ try:
1583
+ mw.layers_dock._refresh_titles_only()
1584
+ except Exception:
1585
+ pass
1586
+
1587
+ def _rename_document(self):
1588
+ current = self.document.display_name()
1589
+ new, ok = QInputDialog.getText(self, self.tr("Rename Document"), self.tr("New document name:"), text=current)
1590
+ if ok and new.strip():
1591
+ # store on the doc so Explorer + other views update too
1592
+ self.document.metadata["display_name"] = new.strip()
1593
+ self.document.changed.emit() # triggers all listeners
1594
+ # If this view had an override equal to the old name, drop it
1595
+ if self._view_title_override and self._view_title_override == current:
1596
+ self._view_title_override = None
1597
+ self._sync_host_title()
1598
+ mw = self._find_main_window()
1599
+ if mw and hasattr(mw, "layers_dock") and mw.layers_dock:
1600
+ try:
1601
+ mw.layers_dock._refresh_titles_only()
1602
+ except Exception:
1603
+ pass
1604
+
1605
+ def set_scale(self, s: float):
1606
+ s = float(max(self._min_scale, min(s, self._max_scale)))
1607
+ if abs(s - self.scale) < 1e-9:
1608
+ return
1609
+ self.scale = s
1610
+ self._render() # only scale needs a redraw
1611
+ self._schedule_emit_view_transform()
1612
+
1613
+
1614
+
1615
+ # ---- view state API (center in image coords + scale) ----
1616
+ #def get_view_state(self) -> dict:
1617
+ # pm = self.label.pixmap()
1618
+ # if pm is None:
1619
+ # return {"scale": self.scale, "center": (0.0, 0.0)}
1620
+ # vp = self.scroll.viewport().size()
1621
+ # hbar = self.scroll.horizontalScrollBar()
1622
+ # vbar = self.scroll.verticalScrollBar()
1623
+ # cx_label = hbar.value() + vp.width() / 2.0
1624
+ # cy_label = vbar.value() + vp.height() / 2.0
1625
+ # return {
1626
+ # "scale": float(self.scale),
1627
+ # "center": (float(cx_label / max(1e-6, self.scale)),
1628
+ # float(cy_label / max(1e-6, self.scale)))
1629
+ # }
1630
+
1631
+ def _start_viewstate_drag(self):
1632
+ """Package view state + robust doc identity into a drag."""
1633
+ hbar = self.scroll.horizontalScrollBar()
1634
+ vbar = self.scroll.verticalScrollBar()
1635
+
1636
+ state = {
1637
+ "doc_ptr": id(self.document), # legacy
1638
+ "scale": float(self.scale),
1639
+ "hval": int(hbar.value()),
1640
+ "vval": int(vbar.value()),
1641
+ "autostretch": bool(self.autostretch_enabled),
1642
+ "autostretch_target": float(self.autostretch_target),
1643
+ }
1644
+ state.update(self._drag_identity_fields()) # uid + base_uid + file_path
1645
+
1646
+ # --- NEW: annotate ROI/source_kind so drop knows this came from a Preview tab
1647
+ roi = None
1648
+ try:
1649
+ if hasattr(self, "has_active_preview") and self.has_active_preview():
1650
+ r = self.current_preview_roi() # (x,y,w,h) in full-image coords
1651
+ if r and len(r) == 4:
1652
+ roi = tuple(map(int, r))
1653
+ except Exception:
1654
+ roi = None
1655
+
1656
+ if roi:
1657
+ state["roi"] = roi
1658
+ state["source_kind"] = "roi-preview"
1659
+ try:
1660
+ pname = self.current_preview_name()
1661
+ except Exception:
1662
+ pname = None
1663
+ if pname:
1664
+ state["preview_name"] = str(pname)
1665
+ else:
1666
+ state["source_kind"] = "full"
1667
+
1668
+ md = QMimeData()
1669
+ md.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
1670
+
1671
+ drag = QDrag(self)
1672
+ drag.setMimeData(md)
1673
+ if self.label.pixmap():
1674
+ drag.setPixmap(self.label.pixmap())
1675
+ drag.exec()
1676
+
1677
+
1678
+
1679
+ def _start_mask_drag(self):
1680
+ """
1681
+ Start a drag that carries 'this document is a mask' to drop targets.
1682
+ """
1683
+ doc = self.document
1684
+ if doc is None:
1685
+ return
1686
+
1687
+ payload = {
1688
+ # New-style field
1689
+ "mask_doc_ptr": id(doc),
1690
+
1691
+ # Backward-compat field: many handlers still look for 'doc_ptr'
1692
+ "doc_ptr": id(doc),
1693
+
1694
+ "mode": "replace", # future: "union"/"intersect"/"diff"
1695
+ "invert": False,
1696
+ "feather": 0.0, # px
1697
+ "name": doc.display_name(),
1698
+ }
1699
+
1700
+ # Add identity hints (uids, base uid, file_path)
1701
+ payload.update(self._drag_identity_fields())
1702
+
1703
+ md = QMimeData()
1704
+ md.setData(MIME_MASK, QByteArray(json.dumps(payload).encode("utf-8")))
1705
+
1706
+ drag = QDrag(self)
1707
+ drag.setMimeData(md)
1708
+ if self.label.pixmap():
1709
+ drag.setPixmap(
1710
+ self.label.pixmap().scaled(
1711
+ 64, 64,
1712
+ Qt.AspectRatioMode.KeepAspectRatio,
1713
+ Qt.TransformationMode.SmoothTransformation,
1714
+ )
1715
+ )
1716
+ drag.setHotSpot(QPoint(16, 16))
1717
+ drag.exec(Qt.DropAction.CopyAction)
1718
+
1719
+ def _start_astrometry_drag(self):
1720
+ """
1721
+ Start a drag that carries 'copy astrometric solution from this document'.
1722
+ We only send a pointer; the main window resolves + copies actual WCS.
1723
+ """
1724
+ payload = {
1725
+ "wcs_from_doc_ptr": id(self.document),
1726
+ "name": self.document.display_name(),
1727
+ }
1728
+ payload.update(self._drag_identity_fields())
1729
+ md = QMimeData()
1730
+ md.setData(MIME_ASTROMETRY, QByteArray(json.dumps(payload).encode("utf-8")))
1731
+
1732
+ drag = QDrag(self)
1733
+ drag.setMimeData(md)
1734
+ if self.label.pixmap():
1735
+ drag.setPixmap(self.label.pixmap().scaled(
1736
+ 64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
1737
+ drag.setHotSpot(QPoint(16, 16))
1738
+ drag.exec(Qt.DropAction.CopyAction)
1739
+
1740
+
1741
+ def apply_view_state(self, st: dict):
1742
+ try:
1743
+ new_scale = float(st.get("scale", self.scale))
1744
+ except Exception:
1745
+ new_scale = self.scale
1746
+ # clamp with new max
1747
+ self.scale = max(self._min_scale, min(new_scale, self._max_scale))
1748
+ self._render(rebuild=False)
1749
+
1750
+ vp = self.scroll.viewport().size()
1751
+ hbar = self.scroll.horizontalScrollBar()
1752
+ vbar = self.scroll.verticalScrollBar()
1753
+
1754
+ if "hval" in st or "vval" in st:
1755
+ # direct scrollbar values (fast path)
1756
+ hv = int(st.get("hval", hbar.value()))
1757
+ vv = int(st.get("vval", vbar.value()))
1758
+ hbar.setValue(hv)
1759
+ vbar.setValue(vv)
1760
+ return
1761
+
1762
+ # fallback: center in image coordinates
1763
+ center = st.get("center")
1764
+ if center is None:
1765
+ return
1766
+ try:
1767
+ cx_img, cy_img = float(center[0]), float(center[1])
1768
+ except Exception:
1769
+ return
1770
+ cx_label = cx_img * self.scale
1771
+ cy_label = cy_img * self.scale
1772
+ hbar.setValue(int(cx_label - vp.width() / 2.0))
1773
+ vbar.setValue(int(cy_label - vp.height() / 2.0))
1774
+ self._emit_view_transform()
1775
+
1776
+
1777
+ # ---- DnD 'view tab' -------------------------------------------------
1778
+ def _install_view_tab(self):
1779
+ self._view_tab = QToolButton(self)
1780
+ self._view_tab.setText(self.tr("View"))
1781
+ self._view_tab.setToolTip(self.tr("Drag onto another window to copy zoom/pan.\n"
1782
+ "Double-click to duplicate this view."))
1783
+ self._view_tab.setCursor(Qt.CursorShape.OpenHandCursor)
1784
+ self._view_tab.setAutoRaise(True)
1785
+ self._view_tab.move(8, 8) # pinned near top-left of the subwindow
1786
+ self._view_tab.show()
1787
+
1788
+ # start drag on press
1789
+ self._view_tab.mousePressEvent = self._viewtab_mouse_press
1790
+ # duplicate on double-click
1791
+ self._view_tab.mouseDoubleClickEvent = self._viewtab_mouse_double
1792
+
1793
+ def _viewtab_mouse_press(self, ev):
1794
+ if ev.button() != Qt.MouseButton.LeftButton:
1795
+ return QToolButton.mousePressEvent(self._view_tab, ev)
1796
+
1797
+ # build the SAME payload schema used by _start_viewstate_drag()
1798
+ hbar = self.scroll.horizontalScrollBar()
1799
+ vbar = self.scroll.verticalScrollBar()
1800
+ state = {
1801
+ "doc_ptr": id(self.document),
1802
+ "scale": float(self.scale),
1803
+ "hval": int(hbar.value()),
1804
+ "vval": int(vbar.value()),
1805
+ "autostretch": bool(self.autostretch_enabled),
1806
+ "autostretch_target": float(self.autostretch_target),
1807
+ }
1808
+ state.update(self._drag_identity_fields())
1809
+
1810
+ mime = QMimeData()
1811
+ mime.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
1812
+
1813
+ drag = QDrag(self)
1814
+ drag.setMimeData(mime)
1815
+
1816
+ pm = self.label.pixmap()
1817
+ if pm:
1818
+ drag.setPixmap(pm.scaled(96, 96,
1819
+ Qt.AspectRatioMode.KeepAspectRatio,
1820
+ Qt.TransformationMode.SmoothTransformation))
1821
+ drag.setHotSpot(QCursor.pos() - self.mapToGlobal(self._view_tab.pos()))
1822
+ drag.exec(Qt.DropAction.CopyAction)
1823
+
1824
+ def _viewtab_mouse_double(self, _ev):
1825
+ # ask main window to duplicate this subwindow
1826
+ self.requestDuplicate.emit(self)
1827
+
1828
+ # accept view-state drops anywhere in the view
1829
+ def dragEnterEvent(self, ev):
1830
+ md = ev.mimeData()
1831
+
1832
+ if (md.hasFormat(MIME_VIEWSTATE)
1833
+ or md.hasFormat(MIME_ASTROMETRY)
1834
+ or md.hasFormat(MIME_MASK)
1835
+ or md.hasFormat(MIME_CMD)
1836
+ or md.hasFormat(MIME_LINKVIEW)):
1837
+ ev.acceptProposedAction()
1838
+ else:
1839
+ ev.ignore()
1840
+
1841
+ def dragMoveEvent(self, ev):
1842
+ md = ev.mimeData()
1843
+
1844
+ if (md.hasFormat(MIME_VIEWSTATE)
1845
+ or md.hasFormat(MIME_ASTROMETRY)
1846
+ or md.hasFormat(MIME_MASK)
1847
+ or md.hasFormat(MIME_CMD)
1848
+ or md.hasFormat(MIME_LINKVIEW)):
1849
+ ev.acceptProposedAction()
1850
+ else:
1851
+ ev.ignore()
1852
+
1853
+ def dropEvent(self, ev):
1854
+ md = ev.mimeData()
1855
+
1856
+ # 0) Function/Action command → forward to main window for headless/UI routing
1857
+ if md.hasFormat(MIME_CMD):
1858
+ try:
1859
+ payload = _unpack_cmd_payload(bytes(md.data(MIME_CMD)))
1860
+ except Exception:
1861
+ ev.ignore(); return
1862
+ mw = self._find_main_window()
1863
+ sw = self._mdi_subwindow()
1864
+ if mw and sw and hasattr(mw, "_handle_command_drop"):
1865
+ mw._handle_command_drop(payload, sw)
1866
+ ev.acceptProposedAction()
1867
+ else:
1868
+ ev.ignore()
1869
+ return
1870
+
1871
+ # 1) view state (existing)
1872
+ if md.hasFormat(MIME_VIEWSTATE):
1873
+ try:
1874
+ st = json.loads(bytes(md.data(MIME_VIEWSTATE)).decode("utf-8"))
1875
+ self.apply_view_state(st)
1876
+ ev.acceptProposedAction()
1877
+ except Exception:
1878
+ ev.ignore()
1879
+ return
1880
+
1881
+ # 2) mask (NEW) → forward to main-window handler using this view as target
1882
+ if md.hasFormat(MIME_MASK):
1883
+ try:
1884
+ payload = json.loads(bytes(md.data(MIME_MASK)).decode("utf-8"))
1885
+ except Exception:
1886
+ ev.ignore(); return
1887
+ mw = self._find_main_window()
1888
+ sw = self._mdi_subwindow()
1889
+ if mw and sw and hasattr(mw, "_handle_mask_drop"):
1890
+ mw._handle_mask_drop(payload, sw)
1891
+ ev.acceptProposedAction()
1892
+ else:
1893
+ ev.ignore()
1894
+ return
1895
+
1896
+ # 3) astrometry (existing forwarding)
1897
+ if md.hasFormat(MIME_ASTROMETRY):
1898
+ try:
1899
+ payload = json.loads(bytes(md.data(MIME_ASTROMETRY)).decode("utf-8"))
1900
+ except Exception:
1901
+ ev.ignore(); return
1902
+ mw = self._find_main_window()
1903
+ sw = self._mdi_subwindow()
1904
+ if mw and hasattr(mw, "_on_astrometry_drop") and sw is not None:
1905
+ mw._on_astrometry_drop(payload, sw)
1906
+ ev.acceptProposedAction()
1907
+ else:
1908
+ ev.ignore()
1909
+ return
1910
+
1911
+ if md.hasFormat(MIME_LINKVIEW):
1912
+ try:
1913
+ payload = json.loads(bytes(md.data(MIME_LINKVIEW)).decode("utf-8"))
1914
+ sid = int(payload.get("source_view_id"))
1915
+ except Exception:
1916
+ ev.ignore(); return
1917
+ src = ImageSubWindow._registry.get(sid)
1918
+ if src is not None and src is not self:
1919
+ src.link_to(self)
1920
+ ev.acceptProposedAction()
1921
+ else:
1922
+ ev.ignore()
1923
+ return
1924
+
1925
+ ev.ignore()
1926
+
1927
+ # keep the tab visible if the widget resizes
1928
+ def resizeEvent(self, ev):
1929
+ super().resizeEvent(ev)
1930
+ try:
1931
+ self.resized.emit()
1932
+ except Exception:
1933
+ pass
1934
+ if hasattr(self, "_view_tab"):
1935
+ self._view_tab.raise_()
1936
+
1937
+ def is_autostretch_linked(self) -> bool:
1938
+ return bool(self._autostretch_linked)
1939
+
1940
+ def set_autostretch_linked(self, linked: bool):
1941
+ linked = bool(linked)
1942
+ if self._autostretch_linked == linked:
1943
+ return
1944
+ self._autostretch_linked = linked
1945
+ if self.autostretch_enabled:
1946
+ self._recompute_autostretch_and_update()
1947
+
1948
+ def _on_docman_nudge(self, *args):
1949
+ # Guard against late signals hitting after destruction/minimize
1950
+ try:
1951
+ from PyQt6 import sip as _sip
1952
+ if _sip.isdeleted(self):
1953
+ return
1954
+ except Exception:
1955
+ pass
1956
+ try:
1957
+ self._refresh_local_undo_buttons()
1958
+ except RuntimeError:
1959
+ # Buttons already gone; safe to ignore
1960
+ pass
1961
+ except Exception:
1962
+ pass
1963
+
1964
+
1965
+ def _recompute_autostretch_and_update(self):
1966
+ self._qimg_src = None # force source rebuild
1967
+ self._render(True)
1968
+
1969
+ def set_doc_manager(self, docman):
1970
+ self._docman = docman
1971
+ try:
1972
+ docman.imageRegionUpdated.connect(self._on_doc_region_updated)
1973
+ docman.imageRegionUpdated.connect(self._on_docman_nudge)
1974
+ if hasattr(docman, "previewRepaintRequested"):
1975
+ docman.previewRepaintRequested.connect(self._on_docman_nudge)
1976
+ except Exception:
1977
+ pass
1978
+
1979
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
1980
+ if base is not None:
1981
+ try:
1982
+ base.changed.connect(self._on_base_doc_changed)
1983
+ except Exception:
1984
+ pass
1985
+ self._install_history_watchers()
1986
+
1987
+ def _on_base_doc_changed(self):
1988
+ # Full-image changes (or unknown) → rebuild our pixmap
1989
+ QTimer.singleShot(0, lambda: (self._render(rebuild=True), self._refresh_local_undo_buttons()))
1990
+
1991
+ def _on_history_doc_changed(self):
1992
+ """
1993
+ Called when the current history document (full or ROI) changes.
1994
+ Ensures the pixmap is rebuilt immediately, including when a
1995
+ tool operates on a Preview/ROI doc.
1996
+ """
1997
+ QTimer.singleShot(0, lambda: (self._render(rebuild=True),
1998
+ self._refresh_local_undo_buttons()))
1999
+
2000
+ def _on_doc_region_updated(self, doc, roi_tuple_or_none):
2001
+ # Only react if it’s our base doc
2002
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
2003
+ if doc is None or base is None or doc is not base:
2004
+ return
2005
+
2006
+ # If not on a Preview tab, just refresh.
2007
+ if not (getattr(self, "_active_source_kind", None) == "preview"
2008
+ and getattr(self, "_active_preview_id", None) is not None):
2009
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2010
+ return
2011
+
2012
+ # We’re on a Preview tab: refresh only if the changed region overlaps our ROI.
2013
+ try:
2014
+ my_roi = self.current_preview_roi() # (x,y,w,h) in full-image coords
2015
+ except Exception:
2016
+ my_roi = None
2017
+
2018
+ if my_roi is None or roi_tuple_or_none is None:
2019
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2020
+ return
2021
+
2022
+ if self._roi_intersects(my_roi, roi_tuple_or_none):
2023
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2024
+
2025
+ @staticmethod
2026
+ def _roi_intersects(a, b):
2027
+ ax, ay, aw, ah = map(int, a)
2028
+ bx, by, bw, bh = map(int, b)
2029
+ if aw <= 0 or ah <= 0 or bw <= 0 or bh <= 0:
2030
+ return False
2031
+ return not (ax+aw <= bx or bx+bw <= ax or ay+ah <= by or by+bh <= ay)
2032
+
2033
+ def refresh_from_docman(self):
2034
+ #print("[ImageSubWindow] refresh_from_docman called")
2035
+ """
2036
+ Called by MainWindow when DocManager says the image changed.
2037
+ We nuke the cached QImage and rebuild from the current doc proxy
2038
+ (which resolves ROI vs full), so the Preview tab repaints correctly.
2039
+ """
2040
+ try:
2041
+ # Invalidate any cached source so _render() fully rebuilds
2042
+ if hasattr(self, "_qimg_src"):
2043
+ self._qimg_src = None
2044
+ except Exception:
2045
+ pass
2046
+ self._render(rebuild=True)
2047
+
2048
+ def _deg_to_hms(self, ra_deg: float) -> str:
2049
+ """RA in degrees → 'HH:MM:SS' (rounded secs, with carry)."""
2050
+ ra_h = ra_deg / 15.0
2051
+ hh = int(ra_h) % 24
2052
+ mmf = (ra_h - hh) * 60.0
2053
+ mm = int(mmf)
2054
+ ss = int(round((mmf - mm) * 60.0))
2055
+ if ss == 60:
2056
+ ss = 0; mm += 1
2057
+ if mm == 60:
2058
+ mm = 0; hh = (hh + 1) % 24
2059
+ return f"{hh:02d}:{mm:02d}:{ss:02d}"
2060
+
2061
+ def _deg_to_dms(self, dec_deg: float) -> str:
2062
+ """Dec in degrees → '±DD:MM:SS' (rounded secs, with carry)."""
2063
+ sign = "+" if dec_deg >= 0 else "-"
2064
+ d = abs(dec_deg)
2065
+ dd = int(d)
2066
+ mf = (d - dd) * 60.0
2067
+ mm = int(mf)
2068
+ ss = int(round((mf - mm) * 60.0))
2069
+ if ss == 60:
2070
+ ss = 0; mm += 1
2071
+ if mm == 60:
2072
+ mm = 0; dd += 1
2073
+ return f"{sign}{dd:02d}:{mm:02d}:{ss:02d}"
2074
+
2075
+
2076
+ # ---------- rendering ----------
2077
+ def _render(self, rebuild: bool = False):
2078
+ """
2079
+ Render the current view.
2080
+
2081
+ Rules:
2082
+ - If a Preview is active, FIRST sync that preview's stored arr from the
2083
+ DocManager's ROI document (the thing tools actually modify), then render.
2084
+ - Never reslice from the parent/full image here.
2085
+ - Keep a strong reference to the numpy buffer that backs the QImage.
2086
+ """
2087
+ # ---- GUARD: widget/label may be deleted but document.changed still fires ----
2088
+ try:
2089
+ from PyQt6 import sip as _sip
2090
+ # If the whole widget or its label is gone, bail immediately
2091
+ if _sip.isdeleted(self):
2092
+ return
2093
+ lbl = getattr(self, "label", None)
2094
+ if lbl is None or _sip.isdeleted(lbl):
2095
+ return
2096
+ except Exception:
2097
+ # If sip or label is missing for any reason, play it safe
2098
+ if not hasattr(self, "label"):
2099
+ return
2100
+ # ---------------------------------------------------------------------------
2101
+ # ---------------------------
2102
+ # 1) Choose & sync source arr
2103
+ # ---------------------------
2104
+ base_img = None
2105
+ if self._active_source_kind == "preview" and self._active_preview_id is not None:
2106
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2107
+ #print("[ImageSubWindow] _render: preview mode, id =", self._active_preview_id, "src =", src is not None)
2108
+ if src is not None:
2109
+ # Pull the *edited* ROI image from DocManager, if available
2110
+ if hasattr(self, "_docman") and self._docman is not None:
2111
+ #print("[ImageSubWindow] _render: pulling edited ROI from DocManager")
2112
+ try:
2113
+ roi_doc = self._docman.get_document_for_view(self)
2114
+ roi_img = getattr(roi_doc, "image", None)
2115
+ if roi_img is not None:
2116
+ # Replace the preview’s static copy with the edited ROI buffer
2117
+ src["arr"] = np.asarray(roi_img).copy()
2118
+ except Exception:
2119
+ print("[ImageSubWindow] _render: failed to pull edited ROI from DocManager")
2120
+ pass
2121
+ base_img = src.get("arr", None)
2122
+ else:
2123
+ #print("[ImageSubWindow] _render: full image mode")
2124
+ base_img = self._display_override if (self._display_override is not None) else (
2125
+ getattr(self.document, "image", None)
2126
+ )
2127
+
2128
+ if base_img is None:
2129
+ self._qimg_src = None
2130
+ self.label.clear()
2131
+ return
2132
+
2133
+ arr = np.asarray(base_img)
2134
+
2135
+ # ---------------------------------------
2136
+ # 2) Normalize dimensionality and dtype
2137
+ # ---------------------------------------
2138
+ # Scalar → 1x1; 1D → 1xN; (H,W,1) → mono (H,W)
2139
+ if arr.ndim == 0:
2140
+ arr = arr.reshape(1, 1)
2141
+ elif arr.ndim == 1:
2142
+ arr = arr[np.newaxis, :]
2143
+ elif arr.ndim == 3 and arr.shape[2] == 1:
2144
+ arr = arr[..., 0]
2145
+
2146
+ is_mono = (arr.ndim == 2)
2147
+
2148
+ # ---------------------------------------
2149
+ # 3) Visualization buffer (float32)
2150
+ # ---------------------------------------
2151
+ if self.autostretch_enabled:
2152
+ if np.issubdtype(arr.dtype, np.integer):
2153
+ info = np.iinfo(arr.dtype)
2154
+ denom = float(max(1, info.max))
2155
+ arr_f = (arr.astype(np.float32) / denom)
2156
+ else:
2157
+ arr_f = arr.astype(np.float32, copy=False)
2158
+ mx = float(arr_f.max()) if arr_f.size else 1.0
2159
+ if mx > 5.0: # compress absurdly large ranges
2160
+ arr_f = arr_f / mx
2161
+
2162
+ vis = autostretch(
2163
+ arr_f,
2164
+ target_median=self.autostretch_target,
2165
+ sigma=self.autostretch_sigma,
2166
+ linked=(not is_mono and self._autostretch_linked),
2167
+ use_16bit=None,
2168
+ )
2169
+ else:
2170
+ vis = arr
2171
+
2172
+ # ---------------------------------------
2173
+ # 4) Convert to 8-bit RGB for QImage
2174
+ # ---------------------------------------
2175
+ if vis.dtype == np.uint8:
2176
+ buf8 = vis
2177
+ elif vis.dtype == np.uint16:
2178
+ buf8 = (vis.astype(np.float32) / 65535.0 * 255.0).clip(0, 255).astype(np.uint8)
2179
+ else:
2180
+ buf8 = (np.clip(vis.astype(np.float32, copy=False), 0.0, 1.0) * 255.0).astype(np.uint8)
2181
+
2182
+ # Force H×W×3
2183
+ if buf8.ndim == 2:
2184
+ buf8 = np.stack([buf8] * 3, axis=-1)
2185
+ elif buf8.ndim == 3:
2186
+ c = buf8.shape[2]
2187
+ if c == 1:
2188
+ buf8 = np.repeat(buf8, 3, axis=2)
2189
+ elif c > 3:
2190
+ buf8 = buf8[..., :3]
2191
+ else:
2192
+ buf8 = np.stack([buf8.squeeze()] * 3, axis=-1)
2193
+
2194
+ # ---------------------------------------
2195
+ # 5) Optional mask overlay
2196
+ # ---------------------------------------
2197
+ if getattr(self, "show_mask_overlay", False):
2198
+ m = self._active_mask_array()
2199
+ if m is not None:
2200
+ if getattr(self, "_mask_overlay_invert", True):
2201
+ m = 1.0 - m
2202
+ th, tw = buf8.shape[:2]
2203
+ sh, sw = m.shape[:2]
2204
+ if (sh, sw) != (th, tw):
2205
+ yi = (np.linspace(0, sh - 1, th)).astype(np.int32)
2206
+ xi = (np.linspace(0, sw - 1, tw)).astype(np.int32)
2207
+ m = m[yi][:, xi]
2208
+ a = m.astype(np.float32, copy=False) * float(getattr(self, "_mask_overlay_alpha", 0.35))
2209
+ bf = buf8.astype(np.float32, copy=False)
2210
+ bf[..., 0] = np.clip(bf[..., 0] + (255.0 - bf[..., 0]) * a, 0.0, 255.0)
2211
+ buf8 = bf.astype(np.uint8, copy=False)
2212
+
2213
+ # ---------------------------------------
2214
+ # 6) Wrap into QImage (keep buffer alive)
2215
+ # ---------------------------------------
2216
+ if buf8.dtype != np.uint8:
2217
+ buf8 = buf8.astype(np.uint8)
2218
+ buf8 = ensure_contiguous(buf8)
2219
+ h, w, c = buf8.shape
2220
+ # Be explicit. RGB888 means 3 bytes per pixel, full stop.
2221
+ bytes_per_line = int(w * 3)
2222
+
2223
+ self._buf8 = buf8 # keep alive
2224
+
2225
+ try:
2226
+ addr = int(self._buf8.ctypes.data)
2227
+ ptr = sip.voidptr(addr)
2228
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
2229
+ # Defensive: if Qt ever decides the buffer looks wrong, force-copy once
2230
+ if qimg is None or qimg.isNull():
2231
+ raise RuntimeError("QImage null")
2232
+ except Exception:
2233
+ # One safe fall-back copy (still fast, avoids crashes)
2234
+ buf8c = np.array(self._buf8, copy=True, order="C")
2235
+ self._buf8 = buf8c
2236
+ addr = int(self._buf8.ctypes.data)
2237
+ ptr = sip.voidptr(addr)
2238
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
2239
+
2240
+ self._qimg_src = qimg
2241
+ if qimg is None or qimg.isNull():
2242
+ self.label.clear()
2243
+ return
2244
+
2245
+ # ---------------------------------------
2246
+ # 7) Scale & present
2247
+ # ---------------------------------------
2248
+ sw = max(1, int(qimg.width() * self.scale))
2249
+ sh = max(1, int(qimg.height() * self.scale))
2250
+ scaled = qimg.scaled(
2251
+ sw, sh,
2252
+ Qt.AspectRatioMode.KeepAspectRatio,
2253
+ Qt.TransformationMode.SmoothTransformation
2254
+ )
2255
+
2256
+ # ── NEW: WCS grid overlay (draw on the scaled pixmap so lines stay 1px) ──
2257
+ if getattr(self, "_show_wcs_grid", False):
2258
+ wcs2 = self._get_celestial_wcs()
2259
+ if wcs2 is not None:
2260
+ from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
2261
+ from PyQt6.QtCore import QSettings
2262
+ from astropy.wcs.utils import proj_plane_pixel_scales
2263
+ import numpy as _np
2264
+
2265
+ pm = QPixmap.fromImage(scaled)
2266
+
2267
+ # Read user prefs (fallback to defaults if not set)
2268
+ _settings = getattr(self, "_settings", None) or QSettings()
2269
+ pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
2270
+ pref_mode = _settings.value("wcs_grid/mode", "auto", type=str) # "auto" | "fixed"
2271
+ pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str) # "deg" | "arcmin"
2272
+ pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
2273
+
2274
+ if not pref_enabled:
2275
+ # User disabled the grid in Preferences — skip overlay
2276
+ self.label.setPixmap(QPixmap.fromImage(scaled))
2277
+ self.label.resize(scaled.size())
2278
+ return
2279
+
2280
+ display_h, display_w = base_img.shape[:2]
2281
+
2282
+ # Pixel scales and FOV using celestial WCS
2283
+ px_scales_deg = proj_plane_pixel_scales(wcs2) # deg/pix for the two celestial axes
2284
+ px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
2285
+
2286
+ H_full, W_full = display_h, display_w
2287
+ fov_deg = px_deg * float(max(W_full, H_full))
2288
+
2289
+ # Choose grid spacing from prefs (or auto heuristic)
2290
+ if pref_mode == "fixed":
2291
+ step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
2292
+ step_deg = max(1e-6, min(step_deg, 90.0)) # clamp to sane range
2293
+ else:
2294
+ # Auto spacing (your previous logic)
2295
+ nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
2296
+ target_lines = 8
2297
+ desired = max(fov_deg / target_lines, px_deg * 100)
2298
+ step_deg = min((n for n in nice if n >= desired), default=30)
2299
+
2300
+ # World rect from image corners using celestial WCS
2301
+ corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
2302
+ try:
2303
+ ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
2304
+ ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
2305
+ dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
2306
+ if ra_max - ra_min > 300:
2307
+ ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
2308
+ ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
2309
+ ra_shift = 180.0
2310
+ else:
2311
+ ra_shift = 0.0
2312
+ except Exception:
2313
+ ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
2314
+
2315
+ p = QPainter(pm)
2316
+ pen = QPen(); pen.setWidth(1); pen.setColor(QColor(255, 255, 255, 140))
2317
+ p.setPen(pen)
2318
+ s = float(self.scale)
2319
+ img_w = int(W_full * s)
2320
+ img_h = int(H_full * s)
2321
+ Wf, Hf = float(W_full), float(H_full)
2322
+ margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
2323
+ def draw_world_poly(xs_world, ys_world):
2324
+ try:
2325
+ px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
2326
+ except Exception:
2327
+ return
2328
+
2329
+ px = _np.asarray(px, dtype=float)
2330
+ py = _np.asarray(py, dtype=float)
2331
+
2332
+ # --- validity mask ---
2333
+ ok = _np.isfinite(px) & _np.isfinite(py)
2334
+
2335
+ # Allow a margin around the image so near-edge lines still draw
2336
+ margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
2337
+ ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
2338
+ ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
2339
+
2340
+ for i in range(1, len(px)):
2341
+ if not (ok[i-1] and ok[i]):
2342
+ continue
2343
+
2344
+ x0 = float(px[i-1]) * s
2345
+ y0 = float(py[i-1]) * s
2346
+ x1 = float(px[i]) * s
2347
+ y1 = float(py[i]) * s
2348
+
2349
+ # Final sanity gate before int() -> Qt 32-bit
2350
+ if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
2351
+ continue
2352
+
2353
+ p.drawLine(int(x0), int(y0), int(x1), int(y1))
2354
+
2355
+
2356
+ ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
2357
+ ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
2358
+ dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
2359
+
2360
+ # DEC lines (horiz-ish)
2361
+ def _frange(a,b,s):
2362
+ out=[]; x=a
2363
+ while x <= b + 1e-9:
2364
+ out.append(x); x += s
2365
+ return out
2366
+ def _round_to(x,s): return s * round(x/s)
2367
+
2368
+ ra_start = _round_to(ra_min, step_deg)
2369
+ dec_start = _round_to(dec_min, step_deg)
2370
+ for dec in _frange(dec_start, dec_max, step_deg):
2371
+ dec_arr = _np.full_like(ra_samples_wrapped, dec)
2372
+ draw_world_poly(ra_samples_wrapped, dec_arr)
2373
+
2374
+ # RA lines (vert-ish)
2375
+ for ra in _frange(ra_start, ra_max, step_deg):
2376
+ ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
2377
+ draw_world_poly(ra_arr, dec_samples)
2378
+
2379
+ # ── LABELS for RA/Dec lines ─────────────────────────────────
2380
+ # Font & box style
2381
+ font = QFont(); font.setPixelSize(11) # screen-consistent
2382
+ p.setFont(font)
2383
+ text_pen = QPen(QColor(255, 255, 255, 230))
2384
+ box_brush = QBrush(QColor(0, 0, 0, 140))
2385
+ p.setPen(text_pen)
2386
+
2387
+ def _draw_label(x, y, txt, anchor="lt"):
2388
+ if not _np.isfinite([x, y]).all():
2389
+ return
2390
+ fm = p.fontMetrics()
2391
+ wtxt = fm.horizontalAdvance(txt) + 6
2392
+ htxt = fm.height() + 4
2393
+
2394
+ # initial placement with a little padding
2395
+ if anchor == "lt": # left-top
2396
+ rx, ry = int(x) + 4, int(y) + 3
2397
+ elif anchor == "rt": # right-top
2398
+ rx, ry = int(x) - wtxt - 4, int(y) + 3
2399
+ elif anchor == "lb": # left-bottom
2400
+ rx, ry = int(x) + 4, int(y) - htxt - 3
2401
+ else: # center-top
2402
+ rx, ry = int(x) - wtxt // 2, int(y) + 3
2403
+
2404
+ # clamp entirely inside the image
2405
+ rx = max(0, min(rx, img_w - wtxt - 1))
2406
+ ry = max(0, min(ry, img_h - htxt - 1))
2407
+
2408
+ rect = QRect(rx, ry, wtxt, htxt)
2409
+ p.save()
2410
+ p.setBrush(box_brush)
2411
+ p.setPen(Qt.PenStyle.NoPen)
2412
+ p.drawRoundedRect(rect, 4, 4)
2413
+ p.restore()
2414
+ p.drawText(rect.adjusted(3, 2, -3, -2),
2415
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
2416
+
2417
+
2418
+ # DEC labels on left edge
2419
+ for dec in _frange(dec_start, dec_max, step_deg):
2420
+ try:
2421
+ x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
2422
+ if not _np.isfinite([x_pix, y_pix]).all():
2423
+ continue
2424
+ # clamp to image bounds before scaling
2425
+ x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2426
+ y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2427
+ _draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
2428
+ except Exception:
2429
+ pass
2430
+
2431
+ # RA labels on top edge
2432
+ for ra in _frange(ra_start, ra_max, step_deg):
2433
+ ra_wrapped = (ra + ra_shift) % 360.0
2434
+ try:
2435
+ x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
2436
+ if not _np.isfinite([x_pix, y_pix]).all():
2437
+ continue
2438
+ x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2439
+ y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2440
+ _draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
2441
+ except Exception:
2442
+ pass
2443
+
2444
+ p.end()
2445
+ scaled = pm.toImage()
2446
+
2447
+ # ── end WCS grid overlay ────────────────────────────────────────────────
2448
+
2449
+ self.label.setPixmap(QPixmap.fromImage(scaled))
2450
+ self.label.resize(scaled.size())
2451
+
2452
+
2453
+
2454
+ def has_active_preview(self) -> bool:
2455
+ return self._active_source_kind == "preview" and self._active_preview_id is not None
2456
+
2457
+ def current_preview_roi(self) -> tuple[int,int,int,int] | None:
2458
+ """
2459
+ Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
2460
+ """
2461
+ if not self.has_active_preview():
2462
+ return None
2463
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2464
+ return None if src is None else tuple(src["roi"])
2465
+
2466
+ def current_preview_name(self) -> str | None:
2467
+ if not self.has_active_preview():
2468
+ return None
2469
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2470
+ return None if src is None else src["name"]
2471
+
2472
+
2473
+ # ---------- interaction ----------
2474
+ def _zoom_at_anchor(self, factor: float):
2475
+ if self._qimg_src is None:
2476
+ return
2477
+ old_scale = self.scale
2478
+ # clamp with new max
2479
+ new_scale = max(self._min_scale, min(old_scale * factor, self._max_scale))
2480
+ if abs(new_scale - old_scale) < 1e-8:
2481
+ return
2482
+
2483
+ vp = self.scroll.viewport()
2484
+ hbar = self.scroll.horizontalScrollBar()
2485
+ vbar = self.scroll.verticalScrollBar()
2486
+
2487
+ # Anchor in viewport coordinates via global cursor (robust)
2488
+ try:
2489
+ anchor_vp = vp.mapFromGlobal(QCursor.pos())
2490
+ except Exception:
2491
+ anchor_vp = None
2492
+
2493
+ if (anchor_vp is None) or (not vp.rect().contains(anchor_vp)):
2494
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
2495
+
2496
+ # Current label coords under the anchor
2497
+ x_label_pre = hbar.value() + anchor_vp.x()
2498
+ y_label_pre = vbar.value() + anchor_vp.y()
2499
+
2500
+ # Convert to image coords at old scale
2501
+ xi = x_label_pre / max(old_scale, 1e-12)
2502
+ yi = y_label_pre / max(old_scale, 1e-12)
2503
+
2504
+ # Apply scale and redraw (updates label size + scrollbar ranges)
2505
+ self.scale = new_scale
2506
+ self._render(rebuild=False)
2507
+
2508
+ # Reproject that image point to label coords at new scale
2509
+ x_label_post = xi * new_scale
2510
+ y_label_post = yi * new_scale
2511
+
2512
+ # Desired scrollbar values to keep point under the cursor
2513
+ new_h = int(round(x_label_post - anchor_vp.x()))
2514
+ new_v = int(round(y_label_post - anchor_vp.y()))
2515
+
2516
+ # Clamp to valid range
2517
+ new_h = max(hbar.minimum(), min(new_h, hbar.maximum()))
2518
+ new_v = max(vbar.minimum(), min(new_v, vbar.maximum()))
2519
+
2520
+ # Apply
2521
+ hbar.setValue(new_h)
2522
+ vbar.setValue(new_v)
2523
+ self._schedule_emit_view_transform()
2524
+
2525
+ def _find_main_window(self):
2526
+ p = self.parent()
2527
+ while p is not None and not hasattr(p, "docman"):
2528
+ p = p.parent()
2529
+ return p
2530
+
2531
+ def event(self, e):
2532
+ """Override event() to handle native macOS gestures (pinch zoom)."""
2533
+ # Handle native gestures (macOS trackpad pinch zoom)
2534
+ if e.type() == QEvent.Type.NativeGesture:
2535
+ gesture_type = e.gestureType()
2536
+
2537
+ if gesture_type == Qt.NativeGestureType.BeginNativeGesture:
2538
+ # Start of pinch gesture - store initial scale
2539
+ self._gesture_zoom_start = self.scale
2540
+ e.accept()
2541
+ return True
2542
+
2543
+ elif gesture_type == Qt.NativeGestureType.ZoomNativeGesture:
2544
+ # Ongoing pinch zoom - value() is cumulative scale factor
2545
+ # Typical values: -0.5 to +0.5 for moderate pinches
2546
+ zoom_delta = e.value()
2547
+
2548
+ # Convert delta to zoom factor
2549
+ # Use smaller multiplier for smoother feel (0.5x damping)
2550
+ factor = 1.0 + (zoom_delta * 0.5)
2551
+
2552
+ # Apply incremental zoom
2553
+ self._zoom_at_anchor(factor)
2554
+ e.accept()
2555
+ return True
2556
+
2557
+ elif gesture_type == Qt.NativeGestureType.EndNativeGesture:
2558
+ # End of pinch gesture - cleanup
2559
+ self._gesture_zoom_start = None
2560
+ e.accept()
2561
+ return True
2562
+
2563
+ # Let parent handle all other events
2564
+ return super().event(e)
2565
+
2566
+
2567
+
2568
+ def eventFilter(self, obj, ev):
2569
+ is_on_view = (obj is self.label) or (obj is self.scroll.viewport())
2570
+
2571
+ # 0) PREVIEW-SELECT MODE: consume mouse events first so earlier branches don't steal them
2572
+ if self._preview_select_mode and is_on_view:
2573
+ vp = self.scroll.viewport()
2574
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
2575
+ vp_pos = obj.mapTo(vp, ev.pos())
2576
+ self._rubber_origin = vp_pos
2577
+ if self._rubber is None:
2578
+ self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, vp)
2579
+ self._rubber.setGeometry(QRect(self._rubber_origin, QSize(1, 1)))
2580
+ self._rubber.show()
2581
+ ev.accept(); return True
2582
+
2583
+ if ev.type() == QEvent.Type.MouseMove and self._rubber is not None and self._rubber_origin is not None:
2584
+ vp_pos = obj.mapTo(vp, ev.pos())
2585
+ rect = QRect(self._rubber_origin, vp_pos).normalized()
2586
+ self._rubber.setGeometry(rect)
2587
+ ev.accept(); return True
2588
+
2589
+ if ev.type() == QEvent.Type.MouseButtonRelease and self._rubber is not None and self._rubber_origin is not None:
2590
+ vp_pos = obj.mapTo(vp, ev.pos())
2591
+ rect = QRect(self._rubber_origin, vp_pos).normalized()
2592
+ self._finish_preview_rect(rect)
2593
+ ev.accept(); return True
2594
+ # don’t swallow unrelated events
2595
+
2596
+ # 1) Ctrl + wheel → zoom
2597
+ if ev.type() == QEvent.Type.Wheel:
2598
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
2599
+ # Try pixelDelta first (macOS trackpad gives smooth values)
2600
+ dy = ev.pixelDelta().y()
2601
+
2602
+ if dy != 0:
2603
+ # Smooth trackpad scrolling: use smaller base factor
2604
+ # Scale proportionally to delta magnitude for natural feel
2605
+ # Typical trackpad deltas are 1-10 pixels per event
2606
+ abs_dy = abs(dy)
2607
+ if abs_dy <= 3:
2608
+ base_factor = 1.01 # Very gentle for tiny movements
2609
+ elif abs_dy <= 10:
2610
+ base_factor = 1.02 # Gentle for small movements
2611
+ else:
2612
+ base_factor = 1.03 # Moderate for larger gestures
2613
+
2614
+ factor = base_factor if dy > 0 else 1/base_factor
2615
+ else:
2616
+ # Traditional mouse wheel: use angleDelta with moderate factor
2617
+ dy = ev.angleDelta().y()
2618
+ if dy == 0:
2619
+ return True
2620
+ # Use 1.15 for mouse wheel (gentler than original 1.25)
2621
+ factor = 1.15 if dy > 0 else 1/1.15
2622
+ self._zoom_at_anchor(factor)
2623
+ return True
2624
+ return False
2625
+
2626
+ # 2) Space+click → start readout
2627
+ if ev.type() == QEvent.Type.MouseButtonPress:
2628
+ if self._space_down and ev.button() == Qt.MouseButton.LeftButton:
2629
+ vp_pos = obj.mapTo(self.scroll.viewport(), ev.pos())
2630
+ res = self._sample_image_at_viewport_pos(vp_pos)
2631
+ if res is not None:
2632
+ xi, yi, sample = res
2633
+ self._show_readout(xi, yi, sample)
2634
+ self._readout_dragging = True
2635
+ return True
2636
+ return False
2637
+
2638
+ # 3) Space+drag → live readout
2639
+ if ev.type() == QEvent.Type.MouseMove:
2640
+ if self._readout_dragging:
2641
+ vp_pos = obj.mapTo(self.scroll.viewport(), ev.pos())
2642
+ res = self._sample_image_at_viewport_pos(vp_pos)
2643
+ if res is not None:
2644
+ xi, yi, sample = res
2645
+ self._show_readout(xi, yi, sample)
2646
+ return True
2647
+ return False
2648
+
2649
+ # 4) Release → stop live readout
2650
+ if ev.type() == QEvent.Type.MouseButtonRelease:
2651
+ if self._readout_dragging:
2652
+ self._readout_dragging = False
2653
+ return True
2654
+ return False
2655
+
2656
+ return super().eventFilter(obj, ev)
2657
+
2658
+
2659
+ def _finish_preview_rect(self, vp_rect: QRect):
2660
+ # Map viewport rectangle into image coordinates
2661
+ if vp_rect.width() < 4 or vp_rect.height() < 4:
2662
+ self._cancel_rubber()
2663
+ return
2664
+
2665
+ hbar = self.scroll.horizontalScrollBar()
2666
+ vbar = self.scroll.verticalScrollBar()
2667
+
2668
+ # Upper-left in label coords
2669
+ x_label0 = hbar.value() + vp_rect.left()
2670
+ y_label0 = vbar.value() + vp_rect.top()
2671
+ x_label1 = hbar.value() + vp_rect.right()
2672
+ y_label1 = vbar.value() + vp_rect.bottom()
2673
+
2674
+ s = max(self.scale, 1e-12)
2675
+
2676
+ x0 = int(round(x_label0 / s))
2677
+ y0 = int(round(y_label0 / s))
2678
+ x1 = int(round(x_label1 / s))
2679
+ y1 = int(round(y_label1 / s))
2680
+
2681
+ if x1 <= x0 or y1 <= y0:
2682
+ self._cancel_rubber()
2683
+ return
2684
+
2685
+ roi = (x0, y0, x1 - x0, y1 - y0)
2686
+ self._create_preview_from_roi(roi)
2687
+ self._cancel_rubber()
2688
+
2689
+ def _create_preview_from_roi(self, roi: tuple[int,int,int,int]):
2690
+ """
2691
+ roi: (x, y, w, h) in FULL IMAGE coordinates
2692
+ """
2693
+ arr = np.asarray(self.document.image)
2694
+ H, W = (arr.shape[0], arr.shape[1]) if arr.ndim >= 2 else (0, 0)
2695
+ x, y, w, h = roi
2696
+ # clamp to image bounds
2697
+ x = max(0, min(x, max(0, W-1)))
2698
+ y = max(0, min(y, max(0, H-1)))
2699
+ w = max(1, min(w, W - x))
2700
+ h = max(1, min(h, H - y))
2701
+
2702
+ crop = arr[y:y+h, x:x+w].copy() # isolate for preview
2703
+
2704
+ pid = self._next_preview_id
2705
+ self._next_preview_id += 1
2706
+ name = self.tr("Preview {0} ({1}×{2})").format(pid, w, h)
2707
+
2708
+ self._previews.append({"id": pid, "name": name, "roi": (x, y, w, h), "arr": crop})
2709
+
2710
+ # Build a tab with a simple QLabel viewer (reuses global rendering through _render)
2711
+ host = QWidget(self)
2712
+ l = QVBoxLayout(host); l.setContentsMargins(0,0,0,0)
2713
+ # For simplicity, we reuse the SAME scroll/label pipeline; the source image is switched in _render
2714
+ # but we still want a local label so the tab displays something. Make a tiny label holder:
2715
+ holder = QLabel(" ") # placeholder; we still render into self.label (single view)
2716
+ holder.setMinimumHeight(1)
2717
+ l.addWidget(holder)
2718
+
2719
+ host._preview_id = pid # attach id for lookups
2720
+ idx = self._tabs.addTab(host, name)
2721
+ self._tabs.setCurrentIndex(idx)
2722
+ self._tabs.tabBar().setVisible(True) # show tabs when first preview appears
2723
+
2724
+ # Switch active source and redraw
2725
+ self._active_source_kind = "preview"
2726
+ self._active_preview_id = pid
2727
+ self._render(True)
2728
+ self._update_replay_button()
2729
+ mw = self._find_main_window()
2730
+ if mw is not None and getattr(mw, "_auto_fit_on_resize", False):
2731
+ try:
2732
+ mw._zoom_active_fit()
2733
+ except Exception:
2734
+ pass
2735
+
2736
+ def mousePressEvent(self, e):
2737
+ # If we're defining a preview ROI, don't start panning here
2738
+ if self._preview_select_mode:
2739
+ e.ignore() # let the eventFilter (label/viewport) handle it
2740
+ return
2741
+
2742
+ if e.button() == Qt.MouseButton.LeftButton:
2743
+ if self._space_down:
2744
+ vp = self.scroll.viewport()
2745
+ vp_pos = vp.mapFrom(self, e.pos())
2746
+ res = self._sample_image_at_viewport_pos(vp_pos)
2747
+ if res is not None:
2748
+ xi, yi, sample = res
2749
+ self._show_readout(xi, yi, sample)
2750
+ self._readout_dragging = True
2751
+ return
2752
+
2753
+ # normal pan mode
2754
+ self._dragging = True
2755
+ self._pan_live = True
2756
+ self._drag_start = e.pos()
2757
+
2758
+ # NEW: emit once at drag start so linked views sync instantly
2759
+ self._emit_view_transform()
2760
+ return
2761
+
2762
+ super().mousePressEvent(e)
2763
+
2764
+
2765
+
2766
+ def _show_readout(self, xi, yi, sample):
2767
+ mw = self._find_main_window()
2768
+ if mw is None:
2769
+ return
2770
+
2771
+ # We want raw float prints, never 16-bit normalized
2772
+ r = g = b = None
2773
+ k = None
2774
+
2775
+ if isinstance(sample, dict):
2776
+ # 1) the clean mono path
2777
+ if "mono" in sample:
2778
+ try:
2779
+ k = float(sample["mono"])
2780
+ except Exception:
2781
+ k = sample["mono"]
2782
+ # 2) the clean RGB path
2783
+ elif all(ch in sample for ch in ("r", "g", "b")):
2784
+ try:
2785
+ r = float(sample["r"])
2786
+ g = float(sample["g"])
2787
+ b = float(sample["b"])
2788
+ except Exception:
2789
+ r = sample["r"]; g = sample["g"]; b = sample["b"]
2790
+ else:
2791
+ # 3) weird dict → just take the first numeric-looking value
2792
+ for v in sample.values():
2793
+ try:
2794
+ k = float(v)
2795
+ break
2796
+ except Exception:
2797
+ continue
2798
+
2799
+ elif isinstance(sample, (list, tuple)):
2800
+ if len(sample) == 1:
2801
+ try:
2802
+ k = float(sample[0])
2803
+ except Exception:
2804
+ k = sample[0]
2805
+ elif len(sample) >= 3:
2806
+ try:
2807
+ r = float(sample[0]); g = float(sample[1]); b = float(sample[2])
2808
+ except Exception:
2809
+ r, g, b = sample[0], sample[1], sample[2]
2810
+
2811
+ else:
2812
+ # numpy scalar / plain number
2813
+ try:
2814
+ k = float(sample)
2815
+ except Exception:
2816
+ k = sample
2817
+
2818
+ msg = f"x={xi} y={yi}"
2819
+
2820
+ if r is not None and g is not None and b is not None:
2821
+ msg += f" R={r:.6f} G={g:.6f} B={b:.6f}"
2822
+ elif k is not None:
2823
+ msg += f" K={k:.6f}"
2824
+ else:
2825
+ # final fallback if everything was weird
2826
+ msg += " K=?"
2827
+
2828
+ # ---- WCS ----
2829
+ wcs2 = self._get_celestial_wcs()
2830
+ if wcs2 is not None:
2831
+ try:
2832
+ ra_deg, dec_deg = map(float, wcs2.pixel_to_world_values(float(xi), float(yi)))
2833
+
2834
+ # RA
2835
+ ra_h = ra_deg / 15.0
2836
+ ra_hh = int(ra_h)
2837
+ ra_mm = int((ra_h - ra_hh) * 60.0)
2838
+ ra_ss = ((ra_h - ra_hh) * 60.0 - ra_mm) * 60.0
2839
+
2840
+ # Dec
2841
+ sign = "+" if dec_deg >= 0 else "-"
2842
+ d = abs(dec_deg)
2843
+ dec_dd = int(d)
2844
+ dec_mm = int((d - dec_dd) * 60.0)
2845
+ dec_ss = ((d - dec_dd) * 60.0 - dec_mm) * 60.0
2846
+
2847
+ msg += (
2848
+ f" RA={ra_hh:02d}:{ra_mm:02d}:{ra_ss:05.2f}"
2849
+ f" Dec={sign}{dec_dd:02d}:{dec_mm:02d}:{dec_ss:05.2f}"
2850
+ )
2851
+ except Exception:
2852
+ pass
2853
+
2854
+ mw.statusBar().showMessage(msg)
2855
+
2856
+
2857
+
2858
+ # 1) helper to build ROI-adjusted WCS (keeps projection/rotation/CD/PC intact)
2859
+ def _wcs_for_roi(self, base_wcs, roi, arr_shape=None):
2860
+ # roi = (x, y, w, h) in FULL-image pixel coords
2861
+ import numpy as np
2862
+ if base_wcs is None or roi is None:
2863
+ return base_wcs
2864
+ x, y, w, h = map(int, roi)
2865
+ wnew = base_wcs.deepcopy()
2866
+ # shift reference pixel into the cropped frame
2867
+ wnew.wcs.crpix = wnew.wcs.crpix - np.array([float(x), float(y)], dtype=float)
2868
+ # tell astropy the new image size for grid/edge computations
2869
+ try:
2870
+ wnew.array_shape = (h, w)
2871
+ wnew.pixel_shape = (w, h)
2872
+ except Exception:
2873
+ pass
2874
+ # prefer 2-D celestial
2875
+ try:
2876
+ cel = getattr(wnew, "celestial", None)
2877
+ if cel is not None and getattr(cel, "naxis", 2) == 2:
2878
+ return cel
2879
+ except Exception:
2880
+ pass
2881
+ return wnew
2882
+
2883
+
2884
+ # 2) make _get_celestial_wcs ROI-aware
2885
+ def _get_celestial_wcs(self):
2886
+ """
2887
+ Return the *correct* celestial WCS for whatever the user is actually
2888
+ seeing in this view.
2889
+
2890
+ - On the Full tab: just use the document's WCS / header.
2891
+ - On a Preview tab: prefer the ROI backing doc's WCS from DocManager.
2892
+ If that's not available, synthesize a cropped header from the base
2893
+ header + preview ROI via _compute_cropped_wcs().
2894
+ """
2895
+ doc = getattr(self, "document", None)
2896
+ if doc is None:
2897
+ return None
2898
+
2899
+ # -----------------------------
2900
+ # FULL IMAGE (no preview active)
2901
+ # -----------------------------
2902
+ if not self.has_active_preview():
2903
+ meta = getattr(doc, "metadata", {}) or {}
2904
+ w = meta.get("wcs")
2905
+ if isinstance(w, _AstroWCS):
2906
+ try:
2907
+ wc = getattr(w, "celestial", None)
2908
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else w
2909
+ except Exception:
2910
+ return w
2911
+
2912
+ hdr = (
2913
+ meta.get("original_header")
2914
+ or meta.get("fits_header")
2915
+ or meta.get("header")
2916
+ )
2917
+ if hdr is None:
2918
+ return None
2919
+
2920
+ w = build_celestial_wcs(hdr)
2921
+ if w is not None:
2922
+ meta["wcs"] = w
2923
+ return w
2924
+
2925
+ # -----------------------------
2926
+ # PREVIEW TAB (ROI view)
2927
+ # -----------------------------
2928
+ roi = self.current_preview_roi()
2929
+ if roi is None:
2930
+ return None
2931
+
2932
+ # Base document is the full image doc; backing_doc may be the ROI doc
2933
+ base_doc = getattr(self, "base_document", None) or doc
2934
+ base_meta = getattr(base_doc, "metadata", {}) or {}
2935
+
2936
+ dm = getattr(self, "_docman", None)
2937
+ backing_doc = None
2938
+ if dm is not None:
2939
+ try:
2940
+ backing_doc = dm.get_document_for_view(self)
2941
+ except Exception:
2942
+ backing_doc = None
2943
+
2944
+ # 1) If DocManager has a separate ROI doc for this view, use ITS WCS
2945
+ if backing_doc is not None and backing_doc is not base_doc:
2946
+ bmeta = getattr(backing_doc, "metadata", {}) or {}
2947
+ w = bmeta.get("wcs")
2948
+ if isinstance(w, _AstroWCS):
2949
+ try:
2950
+ wc = getattr(w, "celestial", None)
2951
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else w
2952
+ except Exception:
2953
+ return w
2954
+
2955
+ hdr = (
2956
+ bmeta.get("original_header")
2957
+ or bmeta.get("fits_header")
2958
+ or bmeta.get("header")
2959
+ )
2960
+ if hdr is not None:
2961
+ w = build_celestial_wcs(hdr)
2962
+ if w is not None:
2963
+ bmeta["wcs"] = w
2964
+ return w
2965
+
2966
+ # 2) Fallback: synthesize cropped WCS from base header + ROI
2967
+ hdr_full = (
2968
+ base_meta.get("original_header")
2969
+ or base_meta.get("fits_header")
2970
+ or base_meta.get("header")
2971
+ )
2972
+ if hdr_full is None:
2973
+ return None
2974
+
2975
+ cache_key = f"_preview_wcs_{self._active_preview_id}"
2976
+ cached = base_meta.get(cache_key)
2977
+ if isinstance(cached, _AstroWCS):
2978
+ try:
2979
+ wc = getattr(cached, "celestial", None)
2980
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else cached
2981
+ except Exception:
2982
+ pass
2983
+
2984
+ try:
2985
+ x, y, w, h = map(int, roi)
2986
+ cropped_hdr = _compute_cropped_wcs(hdr_full, x, y, w, h)
2987
+ wcs = build_celestial_wcs(cropped_hdr)
2988
+ except Exception:
2989
+ wcs = None
2990
+
2991
+ if wcs is not None:
2992
+ base_meta[cache_key] = wcs
2993
+ return wcs
2994
+
2995
+
2996
+ def _extract_wcs_from_doc(self):
2997
+ """
2998
+ Try to get an astropy WCS from the current document or a sensible parent.
2999
+ Caches the resolved WCS on whichever doc we pulled it from.
3000
+ """
3001
+ doc = getattr(self, "document", None)
3002
+ if doc is None:
3003
+ return None
3004
+
3005
+ def _try_on_meta(meta: dict):
3006
+ # (1) literal WCS object stored?
3007
+ w = meta.get("wcs")
3008
+ if isinstance(w, _AstroWCS):
3009
+ return w
3010
+ # (2) any header-like thing present?
3011
+ hdr = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
3012
+ return build_celestial_wcs(hdr)
3013
+
3014
+ # 1) current doc (+ cache)
3015
+ meta = getattr(doc, "metadata", {}) or {}
3016
+ if "_astropy_wcs" in meta:
3017
+ return meta["_astropy_wcs"]
3018
+ w = _try_on_meta(meta)
3019
+ if w is not None:
3020
+ meta["_astropy_wcs"] = w
3021
+ return w
3022
+
3023
+ # 2) likely parents/sources
3024
+ candidates = []
3025
+
3026
+ base = getattr(self, "base_document", None)
3027
+ if base is not None and base is not doc:
3028
+ candidates.append(base)
3029
+
3030
+ dm = getattr(self, "_docman", None)
3031
+ if dm is not None and hasattr(dm, "get_document_for_view"):
3032
+ try:
3033
+ src = dm.get_document_for_view(self)
3034
+ if src is not None and src is not doc and src is not base:
3035
+ candidates.append(src)
3036
+ except Exception:
3037
+ pass
3038
+
3039
+ src_uid = meta.get("wcs_source_doc_uid") or meta.get("base_doc_uid")
3040
+ if src_uid is not None:
3041
+ try:
3042
+ from setiastro.saspro.doc_manager import DocManager
3043
+ reg = getattr(DocManager, "_global_registry", {})
3044
+ by_uid = reg.get(src_uid)
3045
+ if by_uid and by_uid not in candidates and by_uid is not doc and by_uid is not base:
3046
+ candidates.append(by_uid)
3047
+ except Exception:
3048
+ pass
3049
+
3050
+ for cand in candidates:
3051
+ m = getattr(cand, "metadata", {}) or {}
3052
+ if "_astropy_wcs" in m:
3053
+ meta["_astropy_wcs"] = m["_astropy_wcs"]
3054
+ return m["_astropy_wcs"]
3055
+ w = _try_on_meta(m)
3056
+ if w is not None:
3057
+ m["_astropy_wcs"] = w
3058
+ meta["_astropy_wcs"] = w
3059
+ return w
3060
+
3061
+ return None
3062
+
3063
+
3064
+
3065
+ def mouseMoveEvent(self, e):
3066
+ # While defining preview ROI, let the eventFilter drive the QRubberBand
3067
+ if self._preview_select_mode:
3068
+ e.ignore()
3069
+ return
3070
+
3071
+ if self._readout_dragging:
3072
+ vp = self.scroll.viewport()
3073
+ vp_pos = vp.mapFrom(self, e.pos())
3074
+ res = self._sample_image_at_viewport_pos(vp_pos)
3075
+ if res is not None:
3076
+ xi, yi, sample = res
3077
+ self._show_readout(xi, yi, sample)
3078
+ return
3079
+
3080
+ if self._dragging:
3081
+ delta = e.pos() - self._drag_start
3082
+ self.scroll.horizontalScrollBar().setValue(self.scroll.horizontalScrollBar().value() - delta.x())
3083
+ self.scroll.verticalScrollBar().setValue(self.scroll.verticalScrollBar().value() - delta.y())
3084
+ self._drag_start = e.pos()
3085
+ # live emit happens via _on_scroll_changed(), but this is a nice extra nudge:
3086
+ self._emit_view_transform_now()
3087
+ return
3088
+
3089
+ super().mouseMoveEvent(e)
3090
+
3091
+
3092
+
3093
+ def mouseReleaseEvent(self, e):
3094
+ if self._preview_select_mode:
3095
+ e.ignore(); return
3096
+ if e.button() == Qt.MouseButton.LeftButton:
3097
+ self._dragging = False
3098
+ self._pan_live = False # ← back to debounced mode
3099
+ self._readout_dragging = False
3100
+ self._emit_view_transform()
3101
+ return
3102
+ super().mouseReleaseEvent(e)
3103
+
3104
+
3105
+ def closeEvent(self, e):
3106
+ mw = self._find_main_window()
3107
+ doc = getattr(self, "document", None)
3108
+
3109
+ # If main window is force-closing (global exit accepted), don't ask.
3110
+ force = bool(getattr(mw, "_force_close_all", False))
3111
+
3112
+ if not force and doc is not None:
3113
+ # Ask only if this doc has edits
3114
+ should_warn = False
3115
+ if mw and hasattr(mw, "_document_has_edits"):
3116
+ should_warn = mw._document_has_edits(doc)
3117
+ else:
3118
+ # Fallback if called standalone
3119
+ try:
3120
+ should_warn = bool(doc.can_undo())
3121
+ except Exception:
3122
+ should_warn = False
3123
+
3124
+ if should_warn:
3125
+ r = QMessageBox.question(
3126
+ self, self.tr("Close Image?"),
3127
+ self.tr("This image has edits that aren’t applied/saved.\nClose anyway?"),
3128
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
3129
+ QMessageBox.StandardButton.No
3130
+ )
3131
+ if r != QMessageBox.StandardButton.Yes:
3132
+ e.ignore()
3133
+ return
3134
+
3135
+ try:
3136
+ if hasattr(self, "_docman") and self._docman is not None:
3137
+ self._docman.imageRegionUpdated.disconnect(self._on_doc_region_updated)
3138
+ # NEW: also drop the nudge hook(s)
3139
+ try:
3140
+ self._docman.imageRegionUpdated.disconnect(self._on_docman_nudge)
3141
+ except Exception:
3142
+ pass
3143
+ if hasattr(self._docman, "previewRepaintRequested"):
3144
+ try:
3145
+ self._docman.previewRepaintRequested.disconnect(self._on_docman_nudge)
3146
+ except Exception:
3147
+ pass
3148
+ except Exception:
3149
+ pass
3150
+ try:
3151
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
3152
+ if base is not None:
3153
+ base.changed.disconnect(self._on_base_doc_changed)
3154
+ except Exception:
3155
+ pass
3156
+ try:
3157
+ self.unlink_all()
3158
+ except Exception:
3159
+ pass
3160
+ try:
3161
+ if id(self) in ImageSubWindow._registry:
3162
+ ImageSubWindow._registry.pop(id(self), None)
3163
+ except Exception:
3164
+ pass
3165
+ # proceed with your current teardown
3166
+ try:
3167
+ # emit your existing signal if you have it
3168
+ if hasattr(self, "aboutToClose"):
3169
+ self.aboutToClose.emit(doc)
3170
+ except Exception:
3171
+ pass
3172
+ super().closeEvent(e)
3173
+
3174
+ def _resolve_history_doc(self):
3175
+ """
3176
+ Return the doc whose history we should mutate:
3177
+ - If a Preview tab is active → the ROI/proxy doc from DocManager
3178
+ - Otherwise → the base/full document
3179
+ """
3180
+ # Prefer DocManager's ROI-aware mapping if present
3181
+ dm = getattr(self, "_docman", None)
3182
+ if (self._active_source_kind == "preview"
3183
+ and self._active_preview_id is not None
3184
+ and dm is not None
3185
+ and hasattr(dm, "get_document_for_view")):
3186
+ try:
3187
+ d = dm.get_document_for_view(self)
3188
+ if d is not None:
3189
+ return d
3190
+ except Exception:
3191
+ pass
3192
+ # Fallback to the main doc
3193
+ return getattr(self, "document", None)
3194
+
3195
+
3196
+ def _refresh_local_undo_buttons(self):
3197
+ """Enable/disable the local Undo/Redo toolbuttons based on can_undo/can_redo."""
3198
+ try:
3199
+ doc = self._resolve_history_doc()
3200
+ can_u = bool(doc and hasattr(doc, "can_undo") and doc.can_undo())
3201
+ can_r = bool(doc and hasattr(doc, "can_redo") and doc.can_redo())
3202
+ except Exception:
3203
+ can_u = can_r = False
3204
+
3205
+ b_u = getattr(self, "_btn_undo", None)
3206
+ b_r = getattr(self, "_btn_redo", None)
3207
+
3208
+ try:
3209
+ if b_u: b_u.setEnabled(can_u)
3210
+ except RuntimeError:
3211
+ return
3212
+ except Exception:
3213
+ pass
3214
+ try:
3215
+ if b_r: b_r.setEnabled(can_r)
3216
+ except RuntimeError:
3217
+ return
3218
+ except Exception:
3219
+ pass
3220
+
3221
+
3222
+
3223
+ # --- NEW: TableSubWindow -------------------------------------------------
3224
+ from PyQt6.QtWidgets import QTableView, QPushButton, QFileDialog
3225
+
3226
+ class TableSubWindow(QWidget):
3227
+ """
3228
+ Lightweight subwindow to render TableDocument (rows/headers) in a QTableView.
3229
+ Provides: copy, export CSV, row count display.
3230
+ """
3231
+ viewTitleChanged = pyqtSignal(object, str) # to mirror ImageSubWindow emissions (if needed)
3232
+
3233
+ def __init__(self, table_document, parent=None):
3234
+ super().__init__(parent)
3235
+ self.document = table_document
3236
+ self._last_title_for_emit = None
3237
+
3238
+ lyt = QVBoxLayout(self)
3239
+ title_row = QHBoxLayout()
3240
+ self.title_lbl = QLabel(self.document.display_name())
3241
+ title_row.addWidget(self.title_lbl)
3242
+ title_row.addStretch(1)
3243
+
3244
+ self.export_btn = QPushButton(self.tr("Export CSV…"))
3245
+ self.export_btn.clicked.connect(self._export_csv)
3246
+ title_row.addWidget(self.export_btn)
3247
+ lyt.addLayout(title_row)
3248
+
3249
+ self.table = QTableView(self)
3250
+ self.table.setSortingEnabled(True)
3251
+ self.table.setAlternatingRowColors(True)
3252
+ self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
3253
+ self.table.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
3254
+ lyt.addWidget(self.table, 1)
3255
+
3256
+ rows = getattr(self.document, "rows", [])
3257
+ headers = getattr(self.document, "headers", [])
3258
+ self._model = SimpleTableModel(rows, headers, self)
3259
+ self.table.setModel(self._model)
3260
+ self.table.horizontalHeader().setStretchLastSection(True)
3261
+ self.table.resizeColumnsToContents()
3262
+
3263
+ self._sync_host_title()
3264
+ #print(f"[TableSubWindow] init rows={self._model.rowCount()} cols={self._model.columnCount()} title='{self.document.display_name()}'")
3265
+ # react to doc rename if you add such behavior later
3266
+ try:
3267
+ self.document.changed.connect(self._on_doc_changed)
3268
+ except Exception:
3269
+ pass
3270
+
3271
+ def _on_doc_changed(self):
3272
+ # if title changes or content updates in future
3273
+ self.title_lbl.setText(self.document.display_name())
3274
+ self._sync_host_title()
3275
+
3276
+ def _mdi_subwindow(self) -> QMdiSubWindow | None:
3277
+ w = self.parent()
3278
+ while w is not None and not isinstance(w, QMdiSubWindow):
3279
+ w = w.parent()
3280
+ return w
3281
+
3282
+ def _sync_host_title(self):
3283
+ sub = self._mdi_subwindow()
3284
+ if not sub:
3285
+ return
3286
+ title = self.document.display_name()
3287
+ if title != sub.windowTitle():
3288
+ sub.setWindowTitle(title)
3289
+ sub.setToolTip(title)
3290
+ if title != self._last_title_for_emit:
3291
+ self._last_title_for_emit = title
3292
+ try:
3293
+ self.viewTitleChanged.emit(self, title)
3294
+ except Exception:
3295
+ pass
3296
+
3297
+ def _export_csv(self):
3298
+ # Prefer already-exported CSV from metadata when available, otherwise prompt
3299
+ existing = self.document.metadata.get("table_csv")
3300
+ if existing and os.path.exists(existing):
3301
+ # Offer to open/save-as that CSV
3302
+ dst, ok = QFileDialog.getSaveFileName(self, self.tr("Save CSV As…"), os.path.basename(existing), self.tr("CSV Files (*.csv)"))
3303
+ if ok and dst:
3304
+ try:
3305
+ import shutil
3306
+ shutil.copyfile(existing, dst)
3307
+ except Exception as e:
3308
+ QMessageBox.warning(self, self.tr("Export CSV"), self.tr("Failed to copy CSV:\n{0}").format(e))
3309
+ return
3310
+
3311
+ # No pre-export → write one from the model
3312
+ dst, ok = QFileDialog.getSaveFileName(self, self.tr("Export CSV…"), "table.csv", self.tr("CSV Files (*.csv)"))
3313
+ if not ok or not dst:
3314
+ return
3315
+ try:
3316
+ import csv
3317
+ with open(dst, "w", encoding="utf-8", newline="") as f:
3318
+ w = csv.writer(f)
3319
+ # headers
3320
+ cols = self._model.columnCount()
3321
+ hdrs = [self._model.headerData(c, Qt.Orientation.Horizontal) for c in range(cols)]
3322
+ w.writerow([str(h) for h in hdrs])
3323
+ # rows
3324
+ rows = self._model.rowCount()
3325
+ for r in range(rows):
3326
+ w.writerow([self._model.data(self._model.index(r, c), Qt.ItemDataRole.DisplayRole) for c in range(cols)])
3327
+ except Exception as e:
3328
+ QMessageBox.warning(self, self.tr("Export CSV"), self.tr("Failed to export CSV:\n{0}").format(e))