setiastrosuitepro 1.6.7__py3-none-any.whl

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

Potentially problematic release.


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

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