setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,3407 @@
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),
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())
1645
+
1646
+ roi = None
1647
+ try:
1648
+ if hasattr(self, "has_active_preview") and self.has_active_preview():
1649
+ r = self.current_preview_roi()
1650
+ if r and len(r) == 4:
1651
+ roi = tuple(map(int, r))
1652
+ except Exception:
1653
+ roi = None
1654
+
1655
+ if roi:
1656
+ state["roi"] = roi
1657
+ state["source_kind"] = "roi-preview"
1658
+ try:
1659
+ pname = self.current_preview_name()
1660
+ except Exception:
1661
+ pname = None
1662
+ if pname:
1663
+ state["preview_name"] = str(pname)
1664
+ else:
1665
+ state["source_kind"] = "full"
1666
+
1667
+ md = QMimeData()
1668
+ md.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
1669
+
1670
+ drag = QDrag(self)
1671
+ drag.setMimeData(md)
1672
+
1673
+ pm = self.label.pixmap()
1674
+ if pm and not pm.isNull():
1675
+ drag.setPixmap(
1676
+ pm.scaled(
1677
+ 96, 96,
1678
+ Qt.AspectRatioMode.KeepAspectRatio,
1679
+ Qt.TransformationMode.SmoothTransformation,
1680
+ )
1681
+ )
1682
+ drag.setHotSpot(QPoint(16, 16)) # optional, but feels nicer
1683
+
1684
+ drag.exec(Qt.DropAction.CopyAction)
1685
+
1686
+
1687
+
1688
+
1689
+ def _start_mask_drag(self):
1690
+ """
1691
+ Start a drag that carries 'this document is a mask' to drop targets.
1692
+ """
1693
+ doc = self.document
1694
+ if doc is None:
1695
+ return
1696
+
1697
+ payload = {
1698
+ # New-style field
1699
+ "mask_doc_ptr": id(doc),
1700
+
1701
+ # Backward-compat field: many handlers still look for 'doc_ptr'
1702
+ "doc_ptr": id(doc),
1703
+
1704
+ "mode": "replace", # future: "union"/"intersect"/"diff"
1705
+ "invert": False,
1706
+ "feather": 0.0, # px
1707
+ "name": doc.display_name(),
1708
+ }
1709
+
1710
+ # Add identity hints (uids, base uid, file_path)
1711
+ payload.update(self._drag_identity_fields())
1712
+
1713
+ md = QMimeData()
1714
+ md.setData(MIME_MASK, QByteArray(json.dumps(payload).encode("utf-8")))
1715
+
1716
+ drag = QDrag(self)
1717
+ drag.setMimeData(md)
1718
+ if self.label.pixmap():
1719
+ drag.setPixmap(
1720
+ self.label.pixmap().scaled(
1721
+ 64, 64,
1722
+ Qt.AspectRatioMode.KeepAspectRatio,
1723
+ Qt.TransformationMode.SmoothTransformation,
1724
+ )
1725
+ )
1726
+ drag.setHotSpot(QPoint(16, 16))
1727
+ drag.exec(Qt.DropAction.CopyAction)
1728
+
1729
+ def _start_astrometry_drag(self):
1730
+ """
1731
+ Start a drag that carries 'copy astrometric solution from this document'.
1732
+ We only send a pointer; the main window resolves + copies actual WCS.
1733
+ """
1734
+ payload = {
1735
+ "wcs_from_doc_ptr": id(self.document),
1736
+ "name": self.document.display_name(),
1737
+ }
1738
+ payload.update(self._drag_identity_fields())
1739
+ md = QMimeData()
1740
+ md.setData(MIME_ASTROMETRY, QByteArray(json.dumps(payload).encode("utf-8")))
1741
+
1742
+ drag = QDrag(self)
1743
+ drag.setMimeData(md)
1744
+ if self.label.pixmap():
1745
+ drag.setPixmap(self.label.pixmap().scaled(
1746
+ 64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
1747
+ drag.setHotSpot(QPoint(16, 16))
1748
+ drag.exec(Qt.DropAction.CopyAction)
1749
+
1750
+
1751
+ def apply_view_state(self, st: dict):
1752
+ try:
1753
+ new_scale = float(st.get("scale", self.scale))
1754
+ except Exception:
1755
+ new_scale = self.scale
1756
+ # clamp with new max
1757
+ self.scale = max(self._min_scale, min(new_scale, self._max_scale))
1758
+ self._render(rebuild=False)
1759
+
1760
+ vp = self.scroll.viewport().size()
1761
+ hbar = self.scroll.horizontalScrollBar()
1762
+ vbar = self.scroll.verticalScrollBar()
1763
+
1764
+ if "hval" in st or "vval" in st:
1765
+ # direct scrollbar values (fast path)
1766
+ hv = int(st.get("hval", hbar.value()))
1767
+ vv = int(st.get("vval", vbar.value()))
1768
+ hbar.setValue(hv)
1769
+ vbar.setValue(vv)
1770
+ return
1771
+
1772
+ # fallback: center in image coordinates
1773
+ center = st.get("center")
1774
+ if center is None:
1775
+ return
1776
+ try:
1777
+ cx_img, cy_img = float(center[0]), float(center[1])
1778
+ except Exception:
1779
+ return
1780
+ cx_label = cx_img * self.scale
1781
+ cy_label = cy_img * self.scale
1782
+ hbar.setValue(int(cx_label - vp.width() / 2.0))
1783
+ vbar.setValue(int(cy_label - vp.height() / 2.0))
1784
+ self._emit_view_transform()
1785
+
1786
+
1787
+ # ---- DnD 'view tab' -------------------------------------------------
1788
+ def _install_view_tab(self):
1789
+ self._view_tab = QToolButton(self)
1790
+ self._view_tab.setText(self.tr("View"))
1791
+ self._view_tab.setToolTip(self.tr("Drag onto another window to copy zoom/pan.\n"
1792
+ "Double-click to duplicate this view."))
1793
+ self._view_tab.setCursor(Qt.CursorShape.OpenHandCursor)
1794
+ self._view_tab.setAutoRaise(True)
1795
+ self._view_tab.move(8, 8) # pinned near top-left of the subwindow
1796
+ self._view_tab.show()
1797
+
1798
+ # start drag on press
1799
+ self._view_tab.mousePressEvent = self._viewtab_mouse_press
1800
+ # duplicate on double-click
1801
+ self._view_tab.mouseDoubleClickEvent = self._viewtab_mouse_double
1802
+
1803
+ def _viewtab_mouse_press(self, ev):
1804
+ if ev.button() != Qt.MouseButton.LeftButton:
1805
+ return QToolButton.mousePressEvent(self._view_tab, ev)
1806
+
1807
+ # build the SAME payload schema used by _start_viewstate_drag()
1808
+ hbar = self.scroll.horizontalScrollBar()
1809
+ vbar = self.scroll.verticalScrollBar()
1810
+ state = {
1811
+ "doc_ptr": id(self.document),
1812
+ "scale": float(self.scale),
1813
+ "hval": int(hbar.value()),
1814
+ "vval": int(vbar.value()),
1815
+ "autostretch": bool(self.autostretch_enabled),
1816
+ "autostretch_target": float(self.autostretch_target),
1817
+ }
1818
+ state.update(self._drag_identity_fields())
1819
+
1820
+ mime = QMimeData()
1821
+ mime.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
1822
+
1823
+ drag = QDrag(self)
1824
+ drag.setMimeData(mime)
1825
+
1826
+ pm = self.label.pixmap()
1827
+ if pm:
1828
+ drag.setPixmap(pm.scaled(96, 96,
1829
+ Qt.AspectRatioMode.KeepAspectRatio,
1830
+ Qt.TransformationMode.SmoothTransformation))
1831
+ drag.setHotSpot(QCursor.pos() - self.mapToGlobal(self._view_tab.pos()))
1832
+ drag.exec(Qt.DropAction.CopyAction)
1833
+
1834
+ def _viewtab_mouse_double(self, _ev):
1835
+ # ask main window to duplicate this subwindow
1836
+ self.requestDuplicate.emit(self)
1837
+
1838
+ # accept view-state drops anywhere in the view
1839
+ def dragEnterEvent(self, ev):
1840
+ md = ev.mimeData()
1841
+
1842
+ if (md.hasFormat(MIME_VIEWSTATE)
1843
+ or md.hasFormat(MIME_ASTROMETRY)
1844
+ or md.hasFormat(MIME_MASK)
1845
+ or md.hasFormat(MIME_CMD)
1846
+ or md.hasFormat(MIME_LINKVIEW)):
1847
+ ev.acceptProposedAction()
1848
+ else:
1849
+ ev.ignore()
1850
+
1851
+ def dragMoveEvent(self, ev):
1852
+ md = ev.mimeData()
1853
+
1854
+ if (md.hasFormat(MIME_VIEWSTATE)
1855
+ or md.hasFormat(MIME_ASTROMETRY)
1856
+ or md.hasFormat(MIME_MASK)
1857
+ or md.hasFormat(MIME_CMD)
1858
+ or md.hasFormat(MIME_LINKVIEW)):
1859
+ ev.acceptProposedAction()
1860
+ else:
1861
+ ev.ignore()
1862
+
1863
+ def dropEvent(self, ev):
1864
+ md = ev.mimeData()
1865
+
1866
+ # 0) Function/Action command → forward to main window for headless/UI routing
1867
+ if md.hasFormat(MIME_CMD):
1868
+ try:
1869
+ payload = _unpack_cmd_payload(bytes(md.data(MIME_CMD)))
1870
+ except Exception:
1871
+ ev.ignore(); return
1872
+ mw = self._find_main_window()
1873
+ sw = self._mdi_subwindow()
1874
+ if mw and sw and hasattr(mw, "_handle_command_drop"):
1875
+ mw._handle_command_drop(payload, sw)
1876
+ ev.acceptProposedAction()
1877
+ else:
1878
+ ev.ignore()
1879
+ return
1880
+
1881
+ # 1) view state (existing)
1882
+ if md.hasFormat(MIME_VIEWSTATE):
1883
+ try:
1884
+ st = json.loads(bytes(md.data(MIME_VIEWSTATE)).decode("utf-8"))
1885
+ self.apply_view_state(st)
1886
+ ev.acceptProposedAction()
1887
+ except Exception:
1888
+ ev.ignore()
1889
+ return
1890
+
1891
+ # 2) mask (NEW) → forward to main-window handler using this view as target
1892
+ if md.hasFormat(MIME_MASK):
1893
+ try:
1894
+ payload = json.loads(bytes(md.data(MIME_MASK)).decode("utf-8"))
1895
+ except Exception:
1896
+ ev.ignore(); return
1897
+ mw = self._find_main_window()
1898
+ sw = self._mdi_subwindow()
1899
+ if mw and sw and hasattr(mw, "_handle_mask_drop"):
1900
+ mw._handle_mask_drop(payload, sw)
1901
+ ev.acceptProposedAction()
1902
+ else:
1903
+ ev.ignore()
1904
+ return
1905
+
1906
+ # 3) astrometry (existing forwarding)
1907
+ if md.hasFormat(MIME_ASTROMETRY):
1908
+ try:
1909
+ payload = json.loads(bytes(md.data(MIME_ASTROMETRY)).decode("utf-8"))
1910
+ except Exception:
1911
+ ev.ignore(); return
1912
+ mw = self._find_main_window()
1913
+ sw = self._mdi_subwindow()
1914
+ if mw and hasattr(mw, "_on_astrometry_drop") and sw is not None:
1915
+ mw._on_astrometry_drop(payload, sw)
1916
+ ev.acceptProposedAction()
1917
+ else:
1918
+ ev.ignore()
1919
+ return
1920
+
1921
+ if md.hasFormat(MIME_LINKVIEW):
1922
+ try:
1923
+ payload = json.loads(bytes(md.data(MIME_LINKVIEW)).decode("utf-8"))
1924
+ sid = int(payload.get("source_view_id"))
1925
+ except Exception:
1926
+ ev.ignore(); return
1927
+ src = ImageSubWindow._registry.get(sid)
1928
+ if src is not None and src is not self:
1929
+ src.link_to(self)
1930
+ ev.acceptProposedAction()
1931
+ else:
1932
+ ev.ignore()
1933
+ return
1934
+
1935
+ ev.ignore()
1936
+
1937
+ # keep the tab visible if the widget resizes
1938
+ def resizeEvent(self, ev):
1939
+ super().resizeEvent(ev)
1940
+ try:
1941
+ self.resized.emit()
1942
+ except Exception:
1943
+ pass
1944
+ if hasattr(self, "_view_tab"):
1945
+ self._view_tab.raise_()
1946
+
1947
+ def is_autostretch_linked(self) -> bool:
1948
+ return bool(self._autostretch_linked)
1949
+
1950
+ def set_autostretch_linked(self, linked: bool):
1951
+ linked = bool(linked)
1952
+ if self._autostretch_linked == linked:
1953
+ return
1954
+ self._autostretch_linked = linked
1955
+ if self.autostretch_enabled:
1956
+ self._recompute_autostretch_and_update()
1957
+
1958
+ def _on_docman_nudge(self, *args):
1959
+ # Guard against late signals hitting after destruction/minimize
1960
+ try:
1961
+ from PyQt6 import sip as _sip
1962
+ if _sip.isdeleted(self):
1963
+ return
1964
+ except Exception:
1965
+ pass
1966
+ try:
1967
+ self._refresh_local_undo_buttons()
1968
+ except RuntimeError:
1969
+ # Buttons already gone; safe to ignore
1970
+ pass
1971
+ except Exception:
1972
+ pass
1973
+
1974
+
1975
+ def _recompute_autostretch_and_update(self):
1976
+ self._qimg_src = None # force source rebuild
1977
+ self._render(True)
1978
+
1979
+ def set_doc_manager(self, docman):
1980
+ self._docman = docman
1981
+ try:
1982
+ docman.imageRegionUpdated.connect(self._on_doc_region_updated)
1983
+ docman.imageRegionUpdated.connect(self._on_docman_nudge)
1984
+ if hasattr(docman, "previewRepaintRequested"):
1985
+ docman.previewRepaintRequested.connect(self._on_docman_nudge)
1986
+ except Exception:
1987
+ pass
1988
+
1989
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
1990
+ if base is not None:
1991
+ try:
1992
+ base.changed.connect(self._on_base_doc_changed)
1993
+ except Exception:
1994
+ pass
1995
+ self._install_history_watchers()
1996
+
1997
+ def _on_base_doc_changed(self):
1998
+ # Full-image changes (or unknown) → rebuild our pixmap
1999
+ QTimer.singleShot(0, lambda: (self._render(rebuild=True), self._refresh_local_undo_buttons()))
2000
+
2001
+ def _on_history_doc_changed(self):
2002
+ """
2003
+ Called when the current history document (full or ROI) changes.
2004
+ Ensures the pixmap is rebuilt immediately, including when a
2005
+ tool operates on a Preview/ROI doc.
2006
+ """
2007
+ QTimer.singleShot(0, lambda: (self._render(rebuild=True),
2008
+ self._refresh_local_undo_buttons()))
2009
+
2010
+ def _on_doc_region_updated(self, doc, roi_tuple_or_none):
2011
+ # Only react if it’s our base doc
2012
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
2013
+ if doc is None or base is None or doc is not base:
2014
+ return
2015
+
2016
+ # If not on a Preview tab, just refresh.
2017
+ if not (getattr(self, "_active_source_kind", None) == "preview"
2018
+ and getattr(self, "_active_preview_id", None) is not None):
2019
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2020
+ return
2021
+
2022
+ # We’re on a Preview tab: refresh only if the changed region overlaps our ROI.
2023
+ try:
2024
+ my_roi = self.current_preview_roi() # (x,y,w,h) in full-image coords
2025
+ except Exception:
2026
+ my_roi = None
2027
+
2028
+ if my_roi is None or roi_tuple_or_none is None:
2029
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2030
+ return
2031
+
2032
+ if self._roi_intersects(my_roi, roi_tuple_or_none):
2033
+ QTimer.singleShot(0, lambda: self._render(rebuild=True))
2034
+
2035
+ @staticmethod
2036
+ def _roi_intersects(a, b):
2037
+ ax, ay, aw, ah = map(int, a)
2038
+ bx, by, bw, bh = map(int, b)
2039
+ if aw <= 0 or ah <= 0 or bw <= 0 or bh <= 0:
2040
+ return False
2041
+ return not (ax+aw <= bx or bx+bw <= ax or ay+ah <= by or by+bh <= ay)
2042
+
2043
+ def refresh_from_docman(self):
2044
+ #print("[ImageSubWindow] refresh_from_docman called")
2045
+ """
2046
+ Called by MainWindow when DocManager says the image changed.
2047
+ We nuke the cached QImage and rebuild from the current doc proxy
2048
+ (which resolves ROI vs full), so the Preview tab repaints correctly.
2049
+ """
2050
+ try:
2051
+ # Invalidate any cached source so _render() fully rebuilds
2052
+ if hasattr(self, "_qimg_src"):
2053
+ self._qimg_src = None
2054
+ except Exception:
2055
+ pass
2056
+ self._render(rebuild=True)
2057
+
2058
+ def _deg_to_hms(self, ra_deg: float) -> str:
2059
+ """RA in degrees → 'HH:MM:SS' (rounded secs, with carry)."""
2060
+ ra_h = ra_deg / 15.0
2061
+ hh = int(ra_h) % 24
2062
+ mmf = (ra_h - hh) * 60.0
2063
+ mm = int(mmf)
2064
+ ss = int(round((mmf - mm) * 60.0))
2065
+ if ss == 60:
2066
+ ss = 0; mm += 1
2067
+ if mm == 60:
2068
+ mm = 0; hh = (hh + 1) % 24
2069
+ return f"{hh:02d}:{mm:02d}:{ss:02d}"
2070
+
2071
+ def _deg_to_dms(self, dec_deg: float) -> str:
2072
+ """Dec in degrees → '±DD:MM:SS' (rounded secs, with carry)."""
2073
+ sign = "+" if dec_deg >= 0 else "-"
2074
+ d = abs(dec_deg)
2075
+ dd = int(d)
2076
+ mf = (d - dd) * 60.0
2077
+ mm = int(mf)
2078
+ ss = int(round((mf - mm) * 60.0))
2079
+ if ss == 60:
2080
+ ss = 0; mm += 1
2081
+ if mm == 60:
2082
+ mm = 0; dd += 1
2083
+ return f"{sign}{dd:02d}:{mm:02d}:{ss:02d}"
2084
+
2085
+
2086
+ # ---------- rendering ----------
2087
+ def _render(self, rebuild: bool = False):
2088
+ """
2089
+ Render the current view.
2090
+
2091
+ Fast path:
2092
+ - rebuild=False: only rescale already-built pixmap/QImage (NO numpy work).
2093
+ Slow path:
2094
+ - rebuild=True: rebuild visualization (autostretch, 8-bit conversion, overlays),
2095
+ refresh QImage/QPixmap cache, then present.
2096
+
2097
+ Rules:
2098
+ - If a Preview is active, FIRST sync that preview's stored arr from the
2099
+ DocManager's ROI document (the thing tools actually modify), then render.
2100
+ - Never reslice from the parent/full image here.
2101
+ - Keep a strong reference to the numpy buffer that backs the QImage.
2102
+ """
2103
+ # ---- GUARD: widget/label may be deleted but document.changed still fires ----
2104
+ try:
2105
+ from PyQt6 import sip as _sip
2106
+ if _sip.isdeleted(self):
2107
+ return
2108
+ lbl = getattr(self, "label", None)
2109
+ if lbl is None or _sip.isdeleted(lbl):
2110
+ return
2111
+ except Exception:
2112
+ if not hasattr(self, "label"):
2113
+ return
2114
+ # ---------------------------------------------------------------------------
2115
+
2116
+ # ---------------------------------------------------------------------------
2117
+ # FAST PATH: if we're not rebuilding content and we already have a source pixmap,
2118
+ # just present scaled (fast). This is the key to smooth zoom.
2119
+ # ---------------------------------------------------------------------------
2120
+ if (not rebuild) and getattr(self, "_pm_src", None) is not None:
2121
+ self._present_scaled(interactive=True)
2122
+ return
2123
+
2124
+ # ---------------------------
2125
+ # 1) Choose & sync source arr
2126
+ # ---------------------------
2127
+ base_img = None
2128
+ if self._active_source_kind == "preview" and self._active_preview_id is not None:
2129
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2130
+ if src is not None:
2131
+ # Pull the *edited* ROI image from DocManager, if available
2132
+ if hasattr(self, "_docman") and self._docman is not None:
2133
+ try:
2134
+ roi_doc = self._docman.get_document_for_view(self)
2135
+ roi_img = getattr(roi_doc, "image", None)
2136
+ # IMPORTANT: only copy on rebuild; zoom should not trigger a copy
2137
+ if roi_img is not None:
2138
+ if rebuild or ("arr" not in src) or (src.get("arr") is None):
2139
+ src["arr"] = np.asarray(roi_img).copy()
2140
+ except Exception:
2141
+ print("[ImageSubWindow] _render: failed to pull edited ROI from DocManager")
2142
+ base_img = src.get("arr", None)
2143
+ else:
2144
+ base_img = self._display_override if (self._display_override is not None) else (
2145
+ getattr(self.document, "image", None)
2146
+ )
2147
+
2148
+ if base_img is None:
2149
+ self._qimg_src = None
2150
+ self._pm_src = None
2151
+ self._pm_src_wcs = None
2152
+ self._buf8 = None
2153
+ self.label.clear()
2154
+ return
2155
+
2156
+ arr = np.asarray(base_img)
2157
+
2158
+ # ---------------------------------------
2159
+ # 2) Normalize dimensionality and dtype
2160
+ # ---------------------------------------
2161
+ if arr.ndim == 0:
2162
+ arr = arr.reshape(1, 1)
2163
+ elif arr.ndim == 1:
2164
+ arr = arr[np.newaxis, :]
2165
+ elif arr.ndim == 3 and arr.shape[2] == 1:
2166
+ arr = arr[..., 0]
2167
+
2168
+ is_mono = (arr.ndim == 2)
2169
+
2170
+ # ---------------------------------------
2171
+ # 3) Visualization buffer (float32)
2172
+ # ---------------------------------------
2173
+ if self.autostretch_enabled:
2174
+ if np.issubdtype(arr.dtype, np.integer):
2175
+ info = np.iinfo(arr.dtype)
2176
+ denom = float(max(1, info.max))
2177
+ arr_f = (arr.astype(np.float32) / denom)
2178
+ else:
2179
+ arr_f = arr.astype(np.float32, copy=False)
2180
+ mx = float(arr_f.max()) if arr_f.size else 1.0
2181
+ if mx > 5.0:
2182
+ arr_f = arr_f / mx
2183
+
2184
+ vis = autostretch(
2185
+ arr_f,
2186
+ target_median=self.autostretch_target,
2187
+ sigma=self.autostretch_sigma,
2188
+ linked=(not is_mono and self._autostretch_linked),
2189
+ use_16bit=None,
2190
+ )
2191
+ else:
2192
+ vis = arr
2193
+
2194
+ # ---------------------------------------
2195
+ # 4) Convert to 8-bit RGB for QImage
2196
+ # ---------------------------------------
2197
+ if vis.dtype == np.uint8:
2198
+ buf8 = vis
2199
+ elif vis.dtype == np.uint16:
2200
+ buf8 = (vis.astype(np.float32) / 65535.0 * 255.0).clip(0, 255).astype(np.uint8)
2201
+ else:
2202
+ buf8 = (np.clip(vis.astype(np.float32, copy=False), 0.0, 1.0) * 255.0).astype(np.uint8)
2203
+
2204
+ # Force H×W×3
2205
+ if buf8.ndim == 2:
2206
+ buf8 = np.stack([buf8] * 3, axis=-1)
2207
+ elif buf8.ndim == 3:
2208
+ c = buf8.shape[2]
2209
+ if c == 1:
2210
+ buf8 = np.repeat(buf8, 3, axis=2)
2211
+ elif c > 3:
2212
+ buf8 = buf8[..., :3]
2213
+ else:
2214
+ buf8 = np.stack([buf8.squeeze()] * 3, axis=-1)
2215
+
2216
+ # ---------------------------------------
2217
+ # 5) Optional mask overlay (baked into buf8)
2218
+ # ---------------------------------------
2219
+ if getattr(self, "show_mask_overlay", False):
2220
+ m = self._active_mask_array()
2221
+ if m is not None:
2222
+ if getattr(self, "_mask_overlay_invert", True):
2223
+ m = 1.0 - m
2224
+ th, tw = buf8.shape[:2]
2225
+ sh, sw = m.shape[:2]
2226
+ if (sh, sw) != (th, tw):
2227
+ yi = (np.linspace(0, sh - 1, th)).astype(np.int32)
2228
+ xi = (np.linspace(0, sw - 1, tw)).astype(np.int32)
2229
+ m = m[yi][:, xi]
2230
+ a = m.astype(np.float32, copy=False) * float(getattr(self, "_mask_overlay_alpha", 0.35))
2231
+ bf = buf8.astype(np.float32, copy=False)
2232
+ bf[..., 0] = np.clip(bf[..., 0] + (255.0 - bf[..., 0]) * a, 0.0, 255.0)
2233
+ buf8 = bf.astype(np.uint8, copy=False)
2234
+
2235
+ # ---------------------------------------
2236
+ # 6) Wrap into QImage (keep buffer alive)
2237
+ # ---------------------------------------
2238
+ if buf8.dtype != np.uint8:
2239
+ buf8 = buf8.astype(np.uint8)
2240
+
2241
+ buf8 = ensure_contiguous(buf8)
2242
+ h, w, c = buf8.shape
2243
+ bytes_per_line = int(w * 3)
2244
+
2245
+ self._buf8 = buf8 # keep alive
2246
+
2247
+ try:
2248
+ addr = int(self._buf8.ctypes.data)
2249
+ ptr = sip.voidptr(addr)
2250
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
2251
+ if qimg is None or qimg.isNull():
2252
+ raise RuntimeError("QImage null")
2253
+ except Exception:
2254
+ buf8c = np.array(self._buf8, copy=True, order="C")
2255
+ self._buf8 = buf8c
2256
+ addr = int(self._buf8.ctypes.data)
2257
+ ptr = sip.voidptr(addr)
2258
+ qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
2259
+
2260
+ self._qimg_src = qimg
2261
+ if qimg is None or qimg.isNull():
2262
+ self._pm_src = None
2263
+ self._pm_src_wcs = None
2264
+ self.label.clear()
2265
+ return
2266
+
2267
+ # Cache unscaled pixmap ONCE per rebuild
2268
+ self._pm_src = QPixmap.fromImage(self._qimg_src)
2269
+
2270
+ # Invalidate any cached “WCS baked” pixmap on rebuild
2271
+ self._pm_src_wcs = None
2272
+
2273
+ # Present final-quality after rebuild
2274
+ self._present_scaled(interactive=False)
2275
+
2276
+
2277
+ def _present_scaled(self, interactive: bool):
2278
+ """
2279
+ Present the cached source pixmap scaled to current self.scale.
2280
+
2281
+ interactive=True:
2282
+ - Fast scaling
2283
+ - No WCS draw
2284
+ interactive=False:
2285
+ - Smooth scaling
2286
+ - Optionally draw WCS overlay once
2287
+ """
2288
+ if getattr(self, "_pm_src", None) is None:
2289
+ return
2290
+
2291
+ pm_base = self._pm_src
2292
+
2293
+ sw = max(1, int(pm_base.width() * self.scale))
2294
+ sh = max(1, int(pm_base.height() * self.scale))
2295
+
2296
+ mode = Qt.TransformationMode.FastTransformation if interactive else Qt.TransformationMode.SmoothTransformation
2297
+ pm_scaled = pm_base.scaled(sw, sh, Qt.AspectRatioMode.KeepAspectRatio, mode)
2298
+
2299
+ # If interactive, skip WCS overlay entirely (this is the biggest speed win)
2300
+ if interactive:
2301
+ self.label.setPixmap(pm_scaled)
2302
+ self.label.resize(pm_scaled.size())
2303
+ return
2304
+
2305
+ # Non-interactive: (optionally) draw WCS grid.
2306
+ if getattr(self, "_show_wcs_grid", False):
2307
+ # Cache a baked WCS pixmap at *this* scale to avoid re-drawing
2308
+ # if _present_scaled(False) is called multiple times at same scale.
2309
+ cache_key = (sw, sh, float(self.scale))
2310
+ if getattr(self, "_pm_src_wcs_key", None) != cache_key or getattr(self, "_pm_src_wcs", None) is None:
2311
+ pm_scaled = self._draw_wcs_grid_on_pixmap(pm_scaled)
2312
+ self._pm_src_wcs = pm_scaled
2313
+ self._pm_src_wcs_key = cache_key
2314
+ else:
2315
+ pm_scaled = self._pm_src_wcs
2316
+
2317
+ self.label.setPixmap(pm_scaled)
2318
+ self.label.resize(pm_scaled.size())
2319
+
2320
+
2321
+ def _draw_wcs_grid_on_pixmap(self, pm_scaled: QPixmap) -> QPixmap:
2322
+ """
2323
+ Your existing WCS painter logic, moved to operate on a QPixmap (already scaled).
2324
+ Runs ONLY on non-interactive redraw.
2325
+ """
2326
+ wcs2 = self._get_celestial_wcs()
2327
+ if wcs2 is None:
2328
+ return pm_scaled
2329
+
2330
+ from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
2331
+ from PyQt6.QtCore import QSettings, QRect
2332
+ from astropy.wcs.utils import proj_plane_pixel_scales
2333
+ import numpy as _np
2334
+
2335
+ _settings = getattr(self, "_settings", None) or QSettings()
2336
+ pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
2337
+ pref_mode = _settings.value("wcs_grid/mode", "auto", type=str)
2338
+ pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str)
2339
+ pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
2340
+
2341
+ if not pref_enabled:
2342
+ return pm_scaled
2343
+
2344
+ # Determine full image geometry from the CURRENT SOURCE buffer (not pm_scaled)
2345
+ # We can infer W/H from qimg src (original)
2346
+ if getattr(self, "_qimg_src", None) is None:
2347
+ return pm_scaled
2348
+ H_full = int(self._qimg_src.height())
2349
+ W_full = int(self._qimg_src.width())
2350
+
2351
+ # Pixel scales/FOV
2352
+ px_scales_deg = proj_plane_pixel_scales(wcs2)
2353
+ px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
2354
+ fov_deg = px_deg * float(max(W_full, H_full))
2355
+
2356
+ if pref_mode == "fixed":
2357
+ step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
2358
+ step_deg = max(1e-6, min(step_deg, 90.0))
2359
+ else:
2360
+ nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
2361
+ target_lines = 8
2362
+ desired = max(fov_deg / target_lines, px_deg * 100)
2363
+ step_deg = min((n for n in nice if n >= desired), default=30)
2364
+
2365
+ # World bounds from corners
2366
+ corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
2367
+ try:
2368
+ ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
2369
+ ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
2370
+ dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
2371
+ if ra_max - ra_min > 300:
2372
+ ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
2373
+ ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
2374
+ ra_shift = 180.0
2375
+ else:
2376
+ ra_shift = 0.0
2377
+ except Exception:
2378
+ ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
2379
+
2380
+ pm = QPixmap(pm_scaled) # copy so we don’t mutate caller
2381
+ p = QPainter(pm)
2382
+ pen = QPen(QColor(255, 255, 255, 140))
2383
+ pen.setWidth(1)
2384
+ p.setPen(pen)
2385
+
2386
+ # Scale factor between full-res image and pm_scaled
2387
+ s = float(pm.width()) / float(max(1, W_full))
2388
+
2389
+ Wf, Hf = float(W_full), float(H_full)
2390
+
2391
+ def draw_world_poly(xs_world, ys_world):
2392
+ try:
2393
+ px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
2394
+ except Exception:
2395
+ return
2396
+
2397
+ px = _np.asarray(px, dtype=float)
2398
+ py = _np.asarray(py, dtype=float)
2399
+
2400
+ ok = _np.isfinite(px) & _np.isfinite(py)
2401
+ margin = float(max(Wf, Hf) * 2.0)
2402
+ ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
2403
+ ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
2404
+
2405
+ for i in range(1, len(px)):
2406
+ if not (ok[i-1] and ok[i]):
2407
+ continue
2408
+ x0 = float(px[i-1]) * s
2409
+ y0 = float(py[i-1]) * s
2410
+ x1 = float(px[i]) * s
2411
+ y1 = float(py[i]) * s
2412
+ if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
2413
+ continue
2414
+ p.drawLine(int(x0), int(y0), int(x1), int(y1))
2415
+
2416
+ ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
2417
+ ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
2418
+ dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
2419
+
2420
+ def _frange(a, b, sstep):
2421
+ out = []
2422
+ x = a
2423
+ while x <= b + 1e-9:
2424
+ out.append(x)
2425
+ x += sstep
2426
+ return out
2427
+
2428
+ def _round_to(x, sstep):
2429
+ return sstep * round(x / sstep)
2430
+
2431
+ ra_start = _round_to(ra_min, step_deg)
2432
+ dec_start = _round_to(dec_min, step_deg)
2433
+
2434
+ for dec in _frange(dec_start, dec_max, step_deg):
2435
+ dec_arr = _np.full_like(ra_samples_wrapped, dec)
2436
+ draw_world_poly(ra_samples_wrapped, dec_arr)
2437
+
2438
+ for ra in _frange(ra_start, ra_max, step_deg):
2439
+ ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
2440
+ draw_world_poly(ra_arr, dec_samples)
2441
+
2442
+ # Labels
2443
+ font = QFont()
2444
+ font.setPixelSize(11)
2445
+ p.setFont(font)
2446
+ text_pen = QPen(QColor(255, 255, 255, 230))
2447
+ box_brush = QBrush(QColor(0, 0, 0, 140))
2448
+ p.setPen(text_pen)
2449
+
2450
+ img_w = pm.width()
2451
+ img_h = pm.height()
2452
+
2453
+ def _draw_label(x, y, txt, anchor="lt"):
2454
+ if not _np.isfinite([x, y]).all():
2455
+ return
2456
+ fm = p.fontMetrics()
2457
+ wtxt = fm.horizontalAdvance(txt) + 6
2458
+ htxt = fm.height() + 4
2459
+
2460
+ if anchor == "lt":
2461
+ rx, ry = int(x) + 4, int(y) + 3
2462
+ elif anchor == "rt":
2463
+ rx, ry = int(x) - wtxt - 4, int(y) + 3
2464
+ elif anchor == "lb":
2465
+ rx, ry = int(x) + 4, int(y) - htxt - 3
2466
+ else:
2467
+ rx, ry = int(x) - wtxt // 2, int(y) + 3
2468
+
2469
+ rx = max(0, min(rx, img_w - wtxt - 1))
2470
+ ry = max(0, min(ry, img_h - htxt - 1))
2471
+
2472
+ rect = QRect(rx, ry, wtxt, htxt)
2473
+ p.save()
2474
+ p.setBrush(box_brush)
2475
+ p.setPen(Qt.PenStyle.NoPen)
2476
+ p.drawRoundedRect(rect, 4, 4)
2477
+ p.restore()
2478
+ p.drawText(rect.adjusted(3, 2, -3, -2),
2479
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
2480
+
2481
+ # DEC labels on left edge
2482
+ for dec in _frange(dec_start, dec_max, step_deg):
2483
+ try:
2484
+ x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
2485
+ if not _np.isfinite([x_pix, y_pix]).all():
2486
+ continue
2487
+ x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2488
+ y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2489
+ _draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
2490
+ except Exception:
2491
+ pass
2492
+
2493
+ # RA labels on top edge
2494
+ for ra in _frange(ra_start, ra_max, step_deg):
2495
+ ra_wrapped = (ra + ra_shift) % 360.0
2496
+ try:
2497
+ x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
2498
+ if not _np.isfinite([x_pix, y_pix]).all():
2499
+ continue
2500
+ x_pix = min(max(x_pix, 0.0), Wf - 1.0)
2501
+ y_pix = min(max(y_pix, 0.0), Hf - 1.0)
2502
+ _draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
2503
+ except Exception:
2504
+ pass
2505
+
2506
+ p.end()
2507
+ return pm
2508
+
2509
+
2510
+ # ---------- interaction ----------
2511
+ def _zoom_at_anchor(self, factor: float):
2512
+ if getattr(self, "_qimg_src", None) is None and getattr(self, "_pm_src", None) is None:
2513
+ return
2514
+
2515
+ old_scale = float(self.scale)
2516
+ new_scale = max(self._min_scale, min(old_scale * float(factor), self._max_scale))
2517
+ if abs(new_scale - old_scale) < 1e-8:
2518
+ return
2519
+
2520
+ vp = self.scroll.viewport()
2521
+ hbar = self.scroll.horizontalScrollBar()
2522
+ vbar = self.scroll.verticalScrollBar()
2523
+
2524
+ try:
2525
+ anchor_vp = vp.mapFromGlobal(QCursor.pos())
2526
+ except Exception:
2527
+ anchor_vp = None
2528
+
2529
+ if (anchor_vp is None) or (not vp.rect().contains(anchor_vp)):
2530
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
2531
+
2532
+ x_label_pre = hbar.value() + anchor_vp.x()
2533
+ y_label_pre = vbar.value() + anchor_vp.y()
2534
+
2535
+ xi = x_label_pre / max(old_scale, 1e-12)
2536
+ yi = y_label_pre / max(old_scale, 1e-12)
2537
+
2538
+ # Apply new scale
2539
+ self.scale = new_scale
2540
+
2541
+ # FAST present (no rebuild)
2542
+ self._present_scaled(interactive=True)
2543
+
2544
+ # Keep anchor stable
2545
+ x_label_post = xi * new_scale
2546
+ y_label_post = yi * new_scale
2547
+
2548
+ new_h = int(round(x_label_post - anchor_vp.x()))
2549
+ new_v = int(round(y_label_post - anchor_vp.y()))
2550
+
2551
+ new_h = max(hbar.minimum(), min(new_h, hbar.maximum()))
2552
+ new_v = max(vbar.minimum(), min(new_v, vbar.maximum()))
2553
+
2554
+ hbar.setValue(new_h)
2555
+ vbar.setValue(new_v)
2556
+
2557
+ # Defer one final smooth redraw (and WCS overlay) after the burst
2558
+ self._request_zoom_redraw()
2559
+
2560
+
2561
+ def _request_zoom_redraw(self):
2562
+ if getattr(self, "_zoom_timer", None) is None:
2563
+ self._zoom_timer = QTimer(self)
2564
+ self._zoom_timer.setSingleShot(True)
2565
+ self._zoom_timer.timeout.connect(self._apply_zoom_redraw)
2566
+
2567
+ # 60–120ms feels better than 16ms for “zoom burst collapse”
2568
+ # but keep your 16ms if you prefer.
2569
+ self._zoom_timer.start(90)
2570
+
2571
+
2572
+ def _apply_zoom_redraw(self):
2573
+ """
2574
+ Final “settled” redraw:
2575
+ - SmoothTransformation
2576
+ - Optional WCS grid overlay
2577
+ """
2578
+ if getattr(self, "_pm_src", None) is None:
2579
+ return
2580
+ self._present_scaled(interactive=False)
2581
+
2582
+
2583
+
2584
+ def has_active_preview(self) -> bool:
2585
+ return self._active_source_kind == "preview" and self._active_preview_id is not None
2586
+
2587
+ def current_preview_roi(self) -> tuple[int,int,int,int] | None:
2588
+ """
2589
+ Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
2590
+ """
2591
+ if not self.has_active_preview():
2592
+ return None
2593
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2594
+ return None if src is None else tuple(src["roi"])
2595
+
2596
+ def current_preview_name(self) -> str | None:
2597
+ if not self.has_active_preview():
2598
+ return None
2599
+ src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2600
+ return None if src is None else src["name"]
2601
+
2602
+
2603
+
2604
+ def _find_main_window(self):
2605
+ p = self.parent()
2606
+ while p is not None and not hasattr(p, "docman"):
2607
+ p = p.parent()
2608
+ return p
2609
+
2610
+ def event(self, e):
2611
+ """Override event() to handle native macOS gestures (pinch zoom)."""
2612
+ # Handle native gestures (macOS trackpad pinch zoom)
2613
+ if e.type() == QEvent.Type.NativeGesture:
2614
+ gesture_type = e.gestureType()
2615
+
2616
+ if gesture_type == Qt.NativeGestureType.BeginNativeGesture:
2617
+ # Start of pinch gesture - store initial scale
2618
+ self._gesture_zoom_start = self.scale
2619
+ e.accept()
2620
+ return True
2621
+
2622
+ elif gesture_type == Qt.NativeGestureType.ZoomNativeGesture:
2623
+ # Ongoing pinch zoom - value() is cumulative scale factor
2624
+ # Typical values: -0.5 to +0.5 for moderate pinches
2625
+ zoom_delta = e.value()
2626
+
2627
+ # Convert delta to zoom factor
2628
+ # Use smaller multiplier for smoother feel (0.5x damping)
2629
+ factor = 1.0 + (zoom_delta * 0.5)
2630
+
2631
+ # Apply incremental zoom
2632
+ self._zoom_at_anchor(factor)
2633
+ e.accept()
2634
+ return True
2635
+
2636
+ elif gesture_type == Qt.NativeGestureType.EndNativeGesture:
2637
+ # End of pinch gesture - cleanup
2638
+ self._gesture_zoom_start = None
2639
+ e.accept()
2640
+ return True
2641
+
2642
+ # Let parent handle all other events
2643
+ return super().event(e)
2644
+
2645
+
2646
+
2647
+ def eventFilter(self, obj, ev):
2648
+ is_on_view = (obj is self.label) or (obj is self.scroll.viewport())
2649
+
2650
+ # 0) PREVIEW-SELECT MODE: consume mouse events first so earlier branches don't steal them
2651
+ if self._preview_select_mode and is_on_view:
2652
+ vp = self.scroll.viewport()
2653
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
2654
+ vp_pos = obj.mapTo(vp, ev.pos())
2655
+ self._rubber_origin = vp_pos
2656
+ if self._rubber is None:
2657
+ self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, vp)
2658
+ self._rubber.setGeometry(QRect(self._rubber_origin, QSize(1, 1)))
2659
+ self._rubber.show()
2660
+ ev.accept(); return True
2661
+
2662
+ if ev.type() == QEvent.Type.MouseMove and self._rubber is not None and self._rubber_origin is not None:
2663
+ vp_pos = obj.mapTo(vp, ev.pos())
2664
+ rect = QRect(self._rubber_origin, vp_pos).normalized()
2665
+ self._rubber.setGeometry(rect)
2666
+ ev.accept(); return True
2667
+
2668
+ if ev.type() == QEvent.Type.MouseButtonRelease and self._rubber is not None and self._rubber_origin is not None:
2669
+ vp_pos = obj.mapTo(vp, ev.pos())
2670
+ rect = QRect(self._rubber_origin, vp_pos).normalized()
2671
+ self._finish_preview_rect(rect)
2672
+ ev.accept(); return True
2673
+ # don’t swallow unrelated events
2674
+
2675
+ # 1) Ctrl + wheel → zoom
2676
+ if ev.type() == QEvent.Type.Wheel:
2677
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
2678
+ # Try pixelDelta first (macOS trackpad gives smooth values)
2679
+ dy = ev.pixelDelta().y()
2680
+
2681
+ if dy != 0:
2682
+ # Smooth trackpad scrolling: use smaller base factor
2683
+ # Scale proportionally to delta magnitude for natural feel
2684
+ # Typical trackpad deltas are 1-10 pixels per event
2685
+ abs_dy = abs(dy)
2686
+ if abs_dy <= 3:
2687
+ base_factor = 1.01 # Very gentle for tiny movements
2688
+ elif abs_dy <= 10:
2689
+ base_factor = 1.02 # Gentle for small movements
2690
+ else:
2691
+ base_factor = 1.03 # Moderate for larger gestures
2692
+
2693
+ factor = base_factor if dy > 0 else 1/base_factor
2694
+ else:
2695
+ # Traditional mouse wheel: use angleDelta with moderate factor
2696
+ dy = ev.angleDelta().y()
2697
+ if dy == 0:
2698
+ return True
2699
+ # Use 1.15 for mouse wheel (gentler than original 1.25)
2700
+ factor = 1.15 if dy > 0 else 1/1.15
2701
+ self._zoom_at_anchor(factor)
2702
+ return True
2703
+ return False
2704
+
2705
+ # 2) Space+click → start readout
2706
+ if ev.type() == QEvent.Type.MouseButtonPress:
2707
+ if self._space_down and ev.button() == Qt.MouseButton.LeftButton:
2708
+ vp_pos = obj.mapTo(self.scroll.viewport(), ev.pos())
2709
+ res = self._sample_image_at_viewport_pos(vp_pos)
2710
+ if res is not None:
2711
+ xi, yi, sample = res
2712
+ self._show_readout(xi, yi, sample)
2713
+ self._readout_dragging = True
2714
+ return True
2715
+ return False
2716
+
2717
+ # 3) Space+drag → live readout
2718
+ if ev.type() == QEvent.Type.MouseMove:
2719
+ if self._readout_dragging:
2720
+ vp_pos = obj.mapTo(self.scroll.viewport(), ev.pos())
2721
+ res = self._sample_image_at_viewport_pos(vp_pos)
2722
+ if res is not None:
2723
+ xi, yi, sample = res
2724
+ self._show_readout(xi, yi, sample)
2725
+ return True
2726
+ return False
2727
+
2728
+ # 4) Release → stop live readout
2729
+ if ev.type() == QEvent.Type.MouseButtonRelease:
2730
+ if self._readout_dragging:
2731
+ self._readout_dragging = False
2732
+ return True
2733
+ return False
2734
+
2735
+ return super().eventFilter(obj, ev)
2736
+
2737
+
2738
+ def _finish_preview_rect(self, vp_rect: QRect):
2739
+ # Map viewport rectangle into image coordinates
2740
+ if vp_rect.width() < 4 or vp_rect.height() < 4:
2741
+ self._cancel_rubber()
2742
+ return
2743
+
2744
+ hbar = self.scroll.horizontalScrollBar()
2745
+ vbar = self.scroll.verticalScrollBar()
2746
+
2747
+ # Upper-left in label coords
2748
+ x_label0 = hbar.value() + vp_rect.left()
2749
+ y_label0 = vbar.value() + vp_rect.top()
2750
+ x_label1 = hbar.value() + vp_rect.right()
2751
+ y_label1 = vbar.value() + vp_rect.bottom()
2752
+
2753
+ s = max(self.scale, 1e-12)
2754
+
2755
+ x0 = int(round(x_label0 / s))
2756
+ y0 = int(round(y_label0 / s))
2757
+ x1 = int(round(x_label1 / s))
2758
+ y1 = int(round(y_label1 / s))
2759
+
2760
+ if x1 <= x0 or y1 <= y0:
2761
+ self._cancel_rubber()
2762
+ return
2763
+
2764
+ roi = (x0, y0, x1 - x0, y1 - y0)
2765
+ self._create_preview_from_roi(roi)
2766
+ self._cancel_rubber()
2767
+
2768
+ def _create_preview_from_roi(self, roi: tuple[int,int,int,int]):
2769
+ """
2770
+ roi: (x, y, w, h) in FULL IMAGE coordinates
2771
+ """
2772
+ arr = np.asarray(self.document.image)
2773
+ H, W = (arr.shape[0], arr.shape[1]) if arr.ndim >= 2 else (0, 0)
2774
+ x, y, w, h = roi
2775
+ # clamp to image bounds
2776
+ x = max(0, min(x, max(0, W-1)))
2777
+ y = max(0, min(y, max(0, H-1)))
2778
+ w = max(1, min(w, W - x))
2779
+ h = max(1, min(h, H - y))
2780
+
2781
+ crop = arr[y:y+h, x:x+w].copy() # isolate for preview
2782
+
2783
+ pid = self._next_preview_id
2784
+ self._next_preview_id += 1
2785
+ name = self.tr("Preview {0} ({1}×{2})").format(pid, w, h)
2786
+
2787
+ self._previews.append({"id": pid, "name": name, "roi": (x, y, w, h), "arr": crop})
2788
+
2789
+ # Build a tab with a simple QLabel viewer (reuses global rendering through _render)
2790
+ host = QWidget(self)
2791
+ l = QVBoxLayout(host); l.setContentsMargins(0,0,0,0)
2792
+ # For simplicity, we reuse the SAME scroll/label pipeline; the source image is switched in _render
2793
+ # but we still want a local label so the tab displays something. Make a tiny label holder:
2794
+ holder = QLabel(" ") # placeholder; we still render into self.label (single view)
2795
+ holder.setMinimumHeight(1)
2796
+ l.addWidget(holder)
2797
+
2798
+ host._preview_id = pid # attach id for lookups
2799
+ idx = self._tabs.addTab(host, name)
2800
+ self._tabs.setCurrentIndex(idx)
2801
+ self._tabs.tabBar().setVisible(True) # show tabs when first preview appears
2802
+
2803
+ # Switch active source and redraw
2804
+ self._active_source_kind = "preview"
2805
+ self._active_preview_id = pid
2806
+ self._render(True)
2807
+ self._update_replay_button()
2808
+ mw = self._find_main_window()
2809
+ if mw is not None and getattr(mw, "_auto_fit_on_resize", False):
2810
+ try:
2811
+ mw._zoom_active_fit()
2812
+ except Exception:
2813
+ pass
2814
+
2815
+ def mousePressEvent(self, e):
2816
+ # If we're defining a preview ROI, don't start panning here
2817
+ if self._preview_select_mode:
2818
+ e.ignore() # let the eventFilter (label/viewport) handle it
2819
+ return
2820
+
2821
+ if e.button() == Qt.MouseButton.LeftButton:
2822
+ if self._space_down:
2823
+ vp = self.scroll.viewport()
2824
+ vp_pos = vp.mapFrom(self, e.pos())
2825
+ res = self._sample_image_at_viewport_pos(vp_pos)
2826
+ if res is not None:
2827
+ xi, yi, sample = res
2828
+ self._show_readout(xi, yi, sample)
2829
+ self._readout_dragging = True
2830
+ return
2831
+
2832
+ # normal pan mode
2833
+ self._dragging = True
2834
+ self._pan_live = True
2835
+ self._drag_start = e.pos()
2836
+
2837
+ # NEW: emit once at drag start so linked views sync instantly
2838
+ self._emit_view_transform()
2839
+ return
2840
+
2841
+ super().mousePressEvent(e)
2842
+
2843
+
2844
+
2845
+ def _show_readout(self, xi, yi, sample):
2846
+ mw = self._find_main_window()
2847
+ if mw is None:
2848
+ return
2849
+
2850
+ # We want raw float prints, never 16-bit normalized
2851
+ r = g = b = None
2852
+ k = None
2853
+
2854
+ if isinstance(sample, dict):
2855
+ # 1) the clean mono path
2856
+ if "mono" in sample:
2857
+ try:
2858
+ k = float(sample["mono"])
2859
+ except Exception:
2860
+ k = sample["mono"]
2861
+ # 2) the clean RGB path
2862
+ elif all(ch in sample for ch in ("r", "g", "b")):
2863
+ try:
2864
+ r = float(sample["r"])
2865
+ g = float(sample["g"])
2866
+ b = float(sample["b"])
2867
+ except Exception:
2868
+ r = sample["r"]; g = sample["g"]; b = sample["b"]
2869
+ else:
2870
+ # 3) weird dict → just take the first numeric-looking value
2871
+ for v in sample.values():
2872
+ try:
2873
+ k = float(v)
2874
+ break
2875
+ except Exception:
2876
+ continue
2877
+
2878
+ elif isinstance(sample, (list, tuple)):
2879
+ if len(sample) == 1:
2880
+ try:
2881
+ k = float(sample[0])
2882
+ except Exception:
2883
+ k = sample[0]
2884
+ elif len(sample) >= 3:
2885
+ try:
2886
+ r = float(sample[0]); g = float(sample[1]); b = float(sample[2])
2887
+ except Exception:
2888
+ r, g, b = sample[0], sample[1], sample[2]
2889
+
2890
+ else:
2891
+ # numpy scalar / plain number
2892
+ try:
2893
+ k = float(sample)
2894
+ except Exception:
2895
+ k = sample
2896
+
2897
+ msg = f"x={xi} y={yi}"
2898
+
2899
+ if r is not None and g is not None and b is not None:
2900
+ msg += f" R={r:.6f} G={g:.6f} B={b:.6f}"
2901
+ elif k is not None:
2902
+ msg += f" K={k:.6f}"
2903
+ else:
2904
+ # final fallback if everything was weird
2905
+ msg += " K=?"
2906
+
2907
+ # ---- WCS ----
2908
+ wcs2 = self._get_celestial_wcs()
2909
+ if wcs2 is not None:
2910
+ try:
2911
+ ra_deg, dec_deg = map(float, wcs2.pixel_to_world_values(float(xi), float(yi)))
2912
+
2913
+ # RA
2914
+ ra_h = ra_deg / 15.0
2915
+ ra_hh = int(ra_h)
2916
+ ra_mm = int((ra_h - ra_hh) * 60.0)
2917
+ ra_ss = ((ra_h - ra_hh) * 60.0 - ra_mm) * 60.0
2918
+
2919
+ # Dec
2920
+ sign = "+" if dec_deg >= 0 else "-"
2921
+ d = abs(dec_deg)
2922
+ dec_dd = int(d)
2923
+ dec_mm = int((d - dec_dd) * 60.0)
2924
+ dec_ss = ((d - dec_dd) * 60.0 - dec_mm) * 60.0
2925
+
2926
+ msg += (
2927
+ f" RA={ra_hh:02d}:{ra_mm:02d}:{ra_ss:05.2f}"
2928
+ f" Dec={sign}{dec_dd:02d}:{dec_mm:02d}:{dec_ss:05.2f}"
2929
+ )
2930
+ except Exception:
2931
+ pass
2932
+
2933
+ mw.statusBar().showMessage(msg)
2934
+
2935
+
2936
+
2937
+ # 1) helper to build ROI-adjusted WCS (keeps projection/rotation/CD/PC intact)
2938
+ def _wcs_for_roi(self, base_wcs, roi, arr_shape=None):
2939
+ # roi = (x, y, w, h) in FULL-image pixel coords
2940
+ import numpy as np
2941
+ if base_wcs is None or roi is None:
2942
+ return base_wcs
2943
+ x, y, w, h = map(int, roi)
2944
+ wnew = base_wcs.deepcopy()
2945
+ # shift reference pixel into the cropped frame
2946
+ wnew.wcs.crpix = wnew.wcs.crpix - np.array([float(x), float(y)], dtype=float)
2947
+ # tell astropy the new image size for grid/edge computations
2948
+ try:
2949
+ wnew.array_shape = (h, w)
2950
+ wnew.pixel_shape = (w, h)
2951
+ except Exception:
2952
+ pass
2953
+ # prefer 2-D celestial
2954
+ try:
2955
+ cel = getattr(wnew, "celestial", None)
2956
+ if cel is not None and getattr(cel, "naxis", 2) == 2:
2957
+ return cel
2958
+ except Exception:
2959
+ pass
2960
+ return wnew
2961
+
2962
+
2963
+ # 2) make _get_celestial_wcs ROI-aware
2964
+ def _get_celestial_wcs(self):
2965
+ """
2966
+ Return the *correct* celestial WCS for whatever the user is actually
2967
+ seeing in this view.
2968
+
2969
+ - On the Full tab: just use the document's WCS / header.
2970
+ - On a Preview tab: prefer the ROI backing doc's WCS from DocManager.
2971
+ If that's not available, synthesize a cropped header from the base
2972
+ header + preview ROI via _compute_cropped_wcs().
2973
+ """
2974
+ doc = getattr(self, "document", None)
2975
+ if doc is None:
2976
+ return None
2977
+
2978
+ # -----------------------------
2979
+ # FULL IMAGE (no preview active)
2980
+ # -----------------------------
2981
+ if not self.has_active_preview():
2982
+ meta = getattr(doc, "metadata", {}) or {}
2983
+ w = meta.get("wcs")
2984
+ if isinstance(w, _AstroWCS):
2985
+ try:
2986
+ wc = getattr(w, "celestial", None)
2987
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else w
2988
+ except Exception:
2989
+ return w
2990
+
2991
+ hdr = (
2992
+ meta.get("original_header")
2993
+ or meta.get("fits_header")
2994
+ or meta.get("header")
2995
+ )
2996
+ if hdr is None:
2997
+ return None
2998
+
2999
+ w = build_celestial_wcs(hdr)
3000
+ if w is not None:
3001
+ meta["wcs"] = w
3002
+ return w
3003
+
3004
+ # -----------------------------
3005
+ # PREVIEW TAB (ROI view)
3006
+ # -----------------------------
3007
+ roi = self.current_preview_roi()
3008
+ if roi is None:
3009
+ return None
3010
+
3011
+ # Base document is the full image doc; backing_doc may be the ROI doc
3012
+ base_doc = getattr(self, "base_document", None) or doc
3013
+ base_meta = getattr(base_doc, "metadata", {}) or {}
3014
+
3015
+ dm = getattr(self, "_docman", None)
3016
+ backing_doc = None
3017
+ if dm is not None:
3018
+ try:
3019
+ backing_doc = dm.get_document_for_view(self)
3020
+ except Exception:
3021
+ backing_doc = None
3022
+
3023
+ # 1) If DocManager has a separate ROI doc for this view, use ITS WCS
3024
+ if backing_doc is not None and backing_doc is not base_doc:
3025
+ bmeta = getattr(backing_doc, "metadata", {}) or {}
3026
+ w = bmeta.get("wcs")
3027
+ if isinstance(w, _AstroWCS):
3028
+ try:
3029
+ wc = getattr(w, "celestial", None)
3030
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else w
3031
+ except Exception:
3032
+ return w
3033
+
3034
+ hdr = (
3035
+ bmeta.get("original_header")
3036
+ or bmeta.get("fits_header")
3037
+ or bmeta.get("header")
3038
+ )
3039
+ if hdr is not None:
3040
+ w = build_celestial_wcs(hdr)
3041
+ if w is not None:
3042
+ bmeta["wcs"] = w
3043
+ return w
3044
+
3045
+ # 2) Fallback: synthesize cropped WCS from base header + ROI
3046
+ hdr_full = (
3047
+ base_meta.get("original_header")
3048
+ or base_meta.get("fits_header")
3049
+ or base_meta.get("header")
3050
+ )
3051
+ if hdr_full is None:
3052
+ return None
3053
+
3054
+ cache_key = f"_preview_wcs_{self._active_preview_id}"
3055
+ cached = base_meta.get(cache_key)
3056
+ if isinstance(cached, _AstroWCS):
3057
+ try:
3058
+ wc = getattr(cached, "celestial", None)
3059
+ return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else cached
3060
+ except Exception:
3061
+ pass
3062
+
3063
+ try:
3064
+ x, y, w, h = map(int, roi)
3065
+ cropped_hdr = _compute_cropped_wcs(hdr_full, x, y, w, h)
3066
+ wcs = build_celestial_wcs(cropped_hdr)
3067
+ except Exception:
3068
+ wcs = None
3069
+
3070
+ if wcs is not None:
3071
+ base_meta[cache_key] = wcs
3072
+ return wcs
3073
+
3074
+
3075
+ def _extract_wcs_from_doc(self):
3076
+ """
3077
+ Try to get an astropy WCS from the current document or a sensible parent.
3078
+ Caches the resolved WCS on whichever doc we pulled it from.
3079
+ """
3080
+ doc = getattr(self, "document", None)
3081
+ if doc is None:
3082
+ return None
3083
+
3084
+ def _try_on_meta(meta: dict):
3085
+ # (1) literal WCS object stored?
3086
+ w = meta.get("wcs")
3087
+ if isinstance(w, _AstroWCS):
3088
+ return w
3089
+ # (2) any header-like thing present?
3090
+ hdr = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
3091
+ return build_celestial_wcs(hdr)
3092
+
3093
+ # 1) current doc (+ cache)
3094
+ meta = getattr(doc, "metadata", {}) or {}
3095
+ if "_astropy_wcs" in meta:
3096
+ return meta["_astropy_wcs"]
3097
+ w = _try_on_meta(meta)
3098
+ if w is not None:
3099
+ meta["_astropy_wcs"] = w
3100
+ return w
3101
+
3102
+ # 2) likely parents/sources
3103
+ candidates = []
3104
+
3105
+ base = getattr(self, "base_document", None)
3106
+ if base is not None and base is not doc:
3107
+ candidates.append(base)
3108
+
3109
+ dm = getattr(self, "_docman", None)
3110
+ if dm is not None and hasattr(dm, "get_document_for_view"):
3111
+ try:
3112
+ src = dm.get_document_for_view(self)
3113
+ if src is not None and src is not doc and src is not base:
3114
+ candidates.append(src)
3115
+ except Exception:
3116
+ pass
3117
+
3118
+ src_uid = meta.get("wcs_source_doc_uid") or meta.get("base_doc_uid")
3119
+ if src_uid is not None:
3120
+ try:
3121
+ from setiastro.saspro.doc_manager import DocManager
3122
+ reg = getattr(DocManager, "_global_registry", {})
3123
+ by_uid = reg.get(src_uid)
3124
+ if by_uid and by_uid not in candidates and by_uid is not doc and by_uid is not base:
3125
+ candidates.append(by_uid)
3126
+ except Exception:
3127
+ pass
3128
+
3129
+ for cand in candidates:
3130
+ m = getattr(cand, "metadata", {}) or {}
3131
+ if "_astropy_wcs" in m:
3132
+ meta["_astropy_wcs"] = m["_astropy_wcs"]
3133
+ return m["_astropy_wcs"]
3134
+ w = _try_on_meta(m)
3135
+ if w is not None:
3136
+ m["_astropy_wcs"] = w
3137
+ meta["_astropy_wcs"] = w
3138
+ return w
3139
+
3140
+ return None
3141
+
3142
+
3143
+
3144
+ def mouseMoveEvent(self, e):
3145
+ # While defining preview ROI, let the eventFilter drive the QRubberBand
3146
+ if self._preview_select_mode:
3147
+ e.ignore()
3148
+ return
3149
+
3150
+ if self._readout_dragging:
3151
+ vp = self.scroll.viewport()
3152
+ vp_pos = vp.mapFrom(self, e.pos())
3153
+ res = self._sample_image_at_viewport_pos(vp_pos)
3154
+ if res is not None:
3155
+ xi, yi, sample = res
3156
+ self._show_readout(xi, yi, sample)
3157
+ return
3158
+
3159
+ if self._dragging:
3160
+ delta = e.pos() - self._drag_start
3161
+ self.scroll.horizontalScrollBar().setValue(self.scroll.horizontalScrollBar().value() - delta.x())
3162
+ self.scroll.verticalScrollBar().setValue(self.scroll.verticalScrollBar().value() - delta.y())
3163
+ self._drag_start = e.pos()
3164
+ # live emit happens via _on_scroll_changed(), but this is a nice extra nudge:
3165
+ self._emit_view_transform_now()
3166
+ return
3167
+
3168
+ super().mouseMoveEvent(e)
3169
+
3170
+
3171
+
3172
+ def mouseReleaseEvent(self, e):
3173
+ if self._preview_select_mode:
3174
+ e.ignore(); return
3175
+ if e.button() == Qt.MouseButton.LeftButton:
3176
+ self._dragging = False
3177
+ self._pan_live = False # ← back to debounced mode
3178
+ self._readout_dragging = False
3179
+ self._emit_view_transform()
3180
+ return
3181
+ super().mouseReleaseEvent(e)
3182
+
3183
+
3184
+ def closeEvent(self, e):
3185
+ mw = self._find_main_window()
3186
+ doc = getattr(self, "document", None)
3187
+
3188
+ # If main window is force-closing (global exit accepted), don't ask.
3189
+ force = bool(getattr(mw, "_force_close_all", False))
3190
+
3191
+ if not force and doc is not None:
3192
+ # Ask only if this doc has edits
3193
+ should_warn = False
3194
+ if mw and hasattr(mw, "_document_has_edits"):
3195
+ should_warn = mw._document_has_edits(doc)
3196
+ else:
3197
+ # Fallback if called standalone
3198
+ try:
3199
+ should_warn = bool(doc.can_undo())
3200
+ except Exception:
3201
+ should_warn = False
3202
+
3203
+ if should_warn:
3204
+ r = QMessageBox.question(
3205
+ self, self.tr("Close Image?"),
3206
+ self.tr("This image has edits that aren’t applied/saved.\nClose anyway?"),
3207
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
3208
+ QMessageBox.StandardButton.No
3209
+ )
3210
+ if r != QMessageBox.StandardButton.Yes:
3211
+ e.ignore()
3212
+ return
3213
+
3214
+ try:
3215
+ if hasattr(self, "_docman") and self._docman is not None:
3216
+ self._docman.imageRegionUpdated.disconnect(self._on_doc_region_updated)
3217
+ # NEW: also drop the nudge hook(s)
3218
+ try:
3219
+ self._docman.imageRegionUpdated.disconnect(self._on_docman_nudge)
3220
+ except Exception:
3221
+ pass
3222
+ if hasattr(self._docman, "previewRepaintRequested"):
3223
+ try:
3224
+ self._docman.previewRepaintRequested.disconnect(self._on_docman_nudge)
3225
+ except Exception:
3226
+ pass
3227
+ except Exception:
3228
+ pass
3229
+ try:
3230
+ base = getattr(self, "base_document", None) or getattr(self, "document", None)
3231
+ if base is not None:
3232
+ base.changed.disconnect(self._on_base_doc_changed)
3233
+ except Exception:
3234
+ pass
3235
+ try:
3236
+ self.unlink_all()
3237
+ except Exception:
3238
+ pass
3239
+ try:
3240
+ if id(self) in ImageSubWindow._registry:
3241
+ ImageSubWindow._registry.pop(id(self), None)
3242
+ except Exception:
3243
+ pass
3244
+ # proceed with your current teardown
3245
+ try:
3246
+ # emit your existing signal if you have it
3247
+ if hasattr(self, "aboutToClose"):
3248
+ self.aboutToClose.emit(doc)
3249
+ except Exception:
3250
+ pass
3251
+ super().closeEvent(e)
3252
+
3253
+ def _resolve_history_doc(self):
3254
+ """
3255
+ Return the doc whose history we should mutate:
3256
+ - If a Preview tab is active → the ROI/proxy doc from DocManager
3257
+ - Otherwise → the base/full document
3258
+ """
3259
+ # Prefer DocManager's ROI-aware mapping if present
3260
+ dm = getattr(self, "_docman", None)
3261
+ if (self._active_source_kind == "preview"
3262
+ and self._active_preview_id is not None
3263
+ and dm is not None
3264
+ and hasattr(dm, "get_document_for_view")):
3265
+ try:
3266
+ d = dm.get_document_for_view(self)
3267
+ if d is not None:
3268
+ return d
3269
+ except Exception:
3270
+ pass
3271
+ # Fallback to the main doc
3272
+ return getattr(self, "document", None)
3273
+
3274
+
3275
+ def _refresh_local_undo_buttons(self):
3276
+ """Enable/disable the local Undo/Redo toolbuttons based on can_undo/can_redo."""
3277
+ try:
3278
+ doc = self._resolve_history_doc()
3279
+ can_u = bool(doc and hasattr(doc, "can_undo") and doc.can_undo())
3280
+ can_r = bool(doc and hasattr(doc, "can_redo") and doc.can_redo())
3281
+ except Exception:
3282
+ can_u = can_r = False
3283
+
3284
+ b_u = getattr(self, "_btn_undo", None)
3285
+ b_r = getattr(self, "_btn_redo", None)
3286
+
3287
+ try:
3288
+ if b_u: b_u.setEnabled(can_u)
3289
+ except RuntimeError:
3290
+ return
3291
+ except Exception:
3292
+ pass
3293
+ try:
3294
+ if b_r: b_r.setEnabled(can_r)
3295
+ except RuntimeError:
3296
+ return
3297
+ except Exception:
3298
+ pass
3299
+
3300
+
3301
+
3302
+ # --- NEW: TableSubWindow -------------------------------------------------
3303
+ from PyQt6.QtWidgets import QTableView, QPushButton, QFileDialog
3304
+
3305
+ class TableSubWindow(QWidget):
3306
+ """
3307
+ Lightweight subwindow to render TableDocument (rows/headers) in a QTableView.
3308
+ Provides: copy, export CSV, row count display.
3309
+ """
3310
+ viewTitleChanged = pyqtSignal(object, str) # to mirror ImageSubWindow emissions (if needed)
3311
+
3312
+ def __init__(self, table_document, parent=None):
3313
+ super().__init__(parent)
3314
+ self.document = table_document
3315
+ self._last_title_for_emit = None
3316
+
3317
+ lyt = QVBoxLayout(self)
3318
+ title_row = QHBoxLayout()
3319
+ self.title_lbl = QLabel(self.document.display_name())
3320
+ title_row.addWidget(self.title_lbl)
3321
+ title_row.addStretch(1)
3322
+
3323
+ self.export_btn = QPushButton(self.tr("Export CSV…"))
3324
+ self.export_btn.clicked.connect(self._export_csv)
3325
+ title_row.addWidget(self.export_btn)
3326
+ lyt.addLayout(title_row)
3327
+
3328
+ self.table = QTableView(self)
3329
+ self.table.setSortingEnabled(True)
3330
+ self.table.setAlternatingRowColors(True)
3331
+ self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
3332
+ self.table.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
3333
+ lyt.addWidget(self.table, 1)
3334
+
3335
+ rows = getattr(self.document, "rows", [])
3336
+ headers = getattr(self.document, "headers", [])
3337
+ self._model = SimpleTableModel(rows, headers, self)
3338
+ self.table.setModel(self._model)
3339
+ self.table.horizontalHeader().setStretchLastSection(True)
3340
+ self.table.resizeColumnsToContents()
3341
+
3342
+ self._sync_host_title()
3343
+ #print(f"[TableSubWindow] init rows={self._model.rowCount()} cols={self._model.columnCount()} title='{self.document.display_name()}'")
3344
+ # react to doc rename if you add such behavior later
3345
+ try:
3346
+ self.document.changed.connect(self._on_doc_changed)
3347
+ except Exception:
3348
+ pass
3349
+
3350
+ def _on_doc_changed(self):
3351
+ # if title changes or content updates in future
3352
+ self.title_lbl.setText(self.document.display_name())
3353
+ self._sync_host_title()
3354
+
3355
+ def _mdi_subwindow(self) -> QMdiSubWindow | None:
3356
+ w = self.parent()
3357
+ while w is not None and not isinstance(w, QMdiSubWindow):
3358
+ w = w.parent()
3359
+ return w
3360
+
3361
+ def _sync_host_title(self):
3362
+ sub = self._mdi_subwindow()
3363
+ if not sub:
3364
+ return
3365
+ title = self.document.display_name()
3366
+ if title != sub.windowTitle():
3367
+ sub.setWindowTitle(title)
3368
+ sub.setToolTip(title)
3369
+ if title != self._last_title_for_emit:
3370
+ self._last_title_for_emit = title
3371
+ try:
3372
+ self.viewTitleChanged.emit(self, title)
3373
+ except Exception:
3374
+ pass
3375
+
3376
+ def _export_csv(self):
3377
+ # Prefer already-exported CSV from metadata when available, otherwise prompt
3378
+ existing = self.document.metadata.get("table_csv")
3379
+ if existing and os.path.exists(existing):
3380
+ # Offer to open/save-as that CSV
3381
+ dst, ok = QFileDialog.getSaveFileName(self, self.tr("Save CSV As…"), os.path.basename(existing), self.tr("CSV Files (*.csv)"))
3382
+ if ok and dst:
3383
+ try:
3384
+ import shutil
3385
+ shutil.copyfile(existing, dst)
3386
+ except Exception as e:
3387
+ QMessageBox.warning(self, self.tr("Export CSV"), self.tr("Failed to copy CSV:\n{0}").format(e))
3388
+ return
3389
+
3390
+ # No pre-export → write one from the model
3391
+ dst, ok = QFileDialog.getSaveFileName(self, self.tr("Export CSV…"), "table.csv", self.tr("CSV Files (*.csv)"))
3392
+ if not ok or not dst:
3393
+ return
3394
+ try:
3395
+ import csv
3396
+ with open(dst, "w", encoding="utf-8", newline="") as f:
3397
+ w = csv.writer(f)
3398
+ # headers
3399
+ cols = self._model.columnCount()
3400
+ hdrs = [self._model.headerData(c, Qt.Orientation.Horizontal) for c in range(cols)]
3401
+ w.writerow([str(h) for h in hdrs])
3402
+ # rows
3403
+ rows = self._model.rowCount()
3404
+ for r in range(rows):
3405
+ w.writerow([self._model.data(self._model.index(r, c), Qt.ItemDataRole.DisplayRole) for c in range(cols)])
3406
+ except Exception as e:
3407
+ QMessageBox.warning(self, self.tr("Export CSV"), self.tr("Failed to export CSV:\n{0}").format(e))