meltygui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,2111 @@
1
+ """
2
+ Chain-compatible converter nodes for the unified render pipeline.
3
+
4
+ Each function is a @render_func that:
5
+ - Owns its own file I/O (no external load_data / save_data)
6
+ - Manages pending UI (Load / Save / Revert buttons) via draw_state
7
+ - Returns (changed, value) like every other chain node
8
+
9
+ These are NEW functions — the old converters in file_converters.py
10
+ and libcst_conversion.py stay untouched for backward compat.
11
+ """
12
+ import inspect
13
+ from meltygui.core.diagnostics.notifications import lag_traced
14
+
15
+ import os
16
+ import pickle
17
+ import sys
18
+ import threading
19
+ import time
20
+ import tokenize
21
+ import types
22
+ from pathlib import PosixPath, Path
23
+
24
+ import meltygui_imgui as imgui
25
+ import libcst as cst
26
+
27
+ from meltygui.core.melty import FileWatch
28
+ from meltygui.core.melty import Melty
29
+ from meltygui.core.runtime.background import Background
30
+ from meltygui.state.new_core_model import Pin
31
+ from meltygui.state.new_core_model import Anchor
32
+ from meltygui.core.runtime.toggles import Toggles
33
+ from meltygui.core.windowing.glfw_utils import request_render
34
+ from meltygui.core.windowing.glfw_utils import print_stack_trace
35
+ from meltygui.core.conversion.cache_tree import UNSET_VALUE
36
+ from meltygui.core.conversion.path_finder import Pending
37
+ from meltygui.core.conversion.path_finder import PendingState
38
+ from meltygui.core.core_render import render_func
39
+ from meltygui.code.fileref import Address
40
+ from meltygui.code.fileref import to_address
41
+ from meltygui.code.fileref import update_address_cache
42
+ from meltygui.code.fileref import _evict_linecache
43
+ from meltygui.code.fileref import shift_sibling_linenos
44
+ from meltygui.code.file_converters import _detect_newline
45
+ from meltygui.code.file_converters import _split_lines
46
+ from meltygui.code.file_converters import _recompile
47
+ from meltygui.code.file_converters import _recompile_class
48
+ from meltygui.code.file_converters import _recompile_module
49
+ from meltygui.code.libcst_conversion import cst_module_to_dict
50
+ from meltygui.code.libcst_conversion import dict_to_cst_module
51
+ from meltygui.code.libcst_conversion import GeneralParse
52
+ from meltygui.code.libcst_conversion import CallParse
53
+ from meltygui.code.libcst_conversion import CodeLine
54
+ from meltygui.code.libcst_conversion import ClassParse
55
+ from meltygui.code.libcst_conversion import FunctionParse
56
+ from meltygui.code.libcst_conversion import NO_DEFAULT
57
+ from meltygui.core.diagnostics.perf_trace import trace as _ptrace
58
+ from meltygui.core.diagnostics.perf_trace import span as _pspan
59
+ from meltygui.view.header_view import draw_header
60
+ from meltygui.core.rendering.core_decoration import defaults
61
+
62
+
63
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
64
+ # ║ Load node: class → cst.Module ║
65
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
66
+
67
+ def _load_span(ref: Address) -> str:
68
+ """Read the line span from disk."""
69
+ data = ref.path.read_bytes()
70
+ newline = _detect_newline(data)
71
+ try:
72
+ text = data.decode("utf-8")
73
+ except UnicodeDecodeError:
74
+ text = data.decode("latin-1")
75
+ lines = _split_lines(text)
76
+ return newline.join(lines[ref.start:ref.end])
77
+
78
+
79
+ @render_func(use_cache=True)
80
+ def chain_cls_load(input_value, draw_state=None):
81
+ """Load node: class → cst.Module.
82
+
83
+ - Resolves Address from the class on first call
84
+ - Watches file mtime for external changes
85
+ - Shows Load / Revert buttons when file changes on disk
86
+ - Returns (True, cst.Module) when loaded, (False, cached) otherwise
87
+ """
88
+ # ── Resolve ref on first encounter ────────────────────────
89
+ if draw_state._address is None:
90
+ ref = to_address(input_value)
91
+ if ref is None:
92
+ return False, input_value
93
+ draw_state._address = ref
94
+ draw_state._original_input_ref = input_value
95
+
96
+ ref = draw_state._address
97
+
98
+ # ── First load ────────────────────────────────────────────
99
+ if not hasattr(draw_state, '_loaded_text') or draw_state._loaded_text is None:
100
+ text = _load_span(ref)
101
+ draw_state._loaded_text = text
102
+ draw_state._loaded_cst = cst.parse_module(text)
103
+ draw_state.mark_file_current()
104
+ return True, draw_state._loaded_cst
105
+
106
+ imgui.text("chain_cls_load: ")
107
+
108
+ # ── File change detection ─────────────────────────────────
109
+ if draw_state.is_file_stale():
110
+ # Refresh address from cache (another view may have changed line count)
111
+ fresh_ref = to_address(input_value)
112
+ if fresh_ref is not None:
113
+ draw_state._address = fresh_ref
114
+ ref = fresh_ref
115
+
116
+ imgui.text("File changed on disk")
117
+ if imgui.button("Load##chain_load"):
118
+ text = _load_span(ref)
119
+ draw_state._loaded_text = text
120
+ draw_state._loaded_cst = cst.parse_module(text)
121
+ draw_state.mark_file_current()
122
+ return True, draw_state._loaded_cst
123
+
124
+ imgui.same_line()
125
+ if imgui.button("Revert##chain_load"):
126
+ # Return the last loaded cst - no file I/O
127
+ draw_state.mark_file_current()
128
+ return True, draw_state._loaded_cst
129
+
130
+ # ── Steady state ──────────────────────────────────────────
131
+ return False, draw_state._loaded_cst
132
+
133
+
134
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
135
+ # ║ Save chain: cst.Module → class (save to disk + hotswap) ║
136
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
137
+
138
+ @render_func(interrupt_source_for=Path)
139
+ def path_to_address(input_value: Path):
140
+ """Job of interrupt source is to return True when the input has changed on disk"""
141
+ address = Address(input_value)
142
+ return False, address
143
+
144
+
145
+ @render_func()
146
+ def function_to_address(input_value: types.FunctionType, draw_state, changed=False):
147
+ """Extract Address from a function object."""
148
+
149
+ unwrapped = inspect.unwrap(input_value)
150
+ source_file = inspect.getfile(unwrapped)
151
+ FileWatch.register_draw_state(draw_state, Path(source_file))
152
+
153
+ # inspect.getsourcelines reads + tokenizes the whole file (and _evict_linecache
154
+ # forces a fresh read), so it ran every frame while typing. Cache the resolved
155
+ # Address and re-resolve only when the input or the file's mtime changes:
156
+ # typing doesn't write the file, so it's a cache hit; a save bumps mtime and
157
+ # we re-resolve once with fresh line numbers.
158
+ try:
159
+ mtime = Path(source_file).stat().st_mtime
160
+ except OSError:
161
+ mtime = None
162
+ cached = getattr(draw_state, '_addr_cache', None)
163
+ if cached is not None and cached[0] is input_value and cached[1] == mtime:
164
+ return changed, cached[2]
165
+
166
+ _evict_linecache(source_file)
167
+ try:
168
+ source_lines, start_lineno = inspect.getsourcelines(unwrapped)
169
+ except (OSError, TypeError, tokenize.TokenError, SyntaxError) as e:
170
+ print(f"Could not get source lines for {input_value.__name__} in {source_file}: {e}")
171
+ return changed, None
172
+
173
+ address = Address(Path(source_file), start_lineno - 1,
174
+ start_lineno - 1 + len(source_lines), source=input_value,
175
+ watcher_ds=draw_state)
176
+ draw_state._addr_cache = (input_value, mtime, address)
177
+ return changed, address
178
+
179
+
180
+ @render_func()
181
+ def module_to_address(input_value: types.ModuleType, draw_state, changed=False):
182
+ if changed:
183
+ source_file = Path(input_value.__file__)
184
+ FileWatch.register_draw_state(draw_state, source_file)
185
+ if changed:
186
+ pass
187
+
188
+ return changed, Address(source_file, source=input_value, watcher_ds=draw_state)
189
+ else:
190
+ return changed, None
191
+
192
+
193
+ ########################################### CHAIN START
194
+ @render_func()
195
+ def class_to_address(input_value: type, draw_state, changed=False):
196
+ # Resolve on EVERY call (guarded by the mtime cache), like function_to_address
197
+ # - NOT gated on `changed`. Gating let the cached span go stale after a sibling
198
+ # edit shifted the class's lines, so a save wrote to the wrong range. A save
199
+ # bumps mtime, so the next render re-resolves fresh line numbers.
200
+ if not isinstance(input_value, type) or input_value.__module__ in ('builtins', '_collections_abc'):
201
+ return changed, None
202
+ # A runtime-generated bubbling class has no source of its own - resolve its base.
203
+ from meltygui.core.conversion.bubbling import base_of_bubbling
204
+ input_value = base_of_bubbling(input_value)
205
+ try:
206
+ import inspect
207
+ source_file = inspect.getfile(input_value)
208
+ FileWatch.register_draw_state(draw_state, Path(source_file))
209
+
210
+ # Cache the getsourcelines result by (input, file mtime) so typing
211
+ # doesn't re-read+tokenize the file; a save bumps mtime and we re-resolve.
212
+ try:
213
+ mtime = Path(source_file).stat().st_mtime
214
+ except OSError:
215
+ mtime = None
216
+ cached = getattr(draw_state, '_addr_cache', None)
217
+ if cached is not None and cached[0] is input_value and cached[1] == mtime:
218
+ return changed, cached[2]
219
+
220
+ _evict_linecache(source_file)
221
+ source_lines, start_lineno = inspect.getsourcelines(input_value)
222
+ address = Address(Path(source_file), start_lineno - 1,
223
+ start_lineno - 1 + len(source_lines), source=input_value,
224
+ watcher_ds=draw_state)
225
+ draw_state._addr_cache = (input_value, mtime, address)
226
+ return changed, address
227
+ except (TypeError, OSError, tokenize.TokenError, SyntaxError):
228
+ return changed, None
229
+
230
+
231
+ @render_func()
232
+ def class_to_address_incl_overrides(input_value: type, draw_state, changed=False):
233
+ """Like class_to_address, but extends the span UPWARD over a contiguous
234
+ leading `# [...]` override comment immediately above the class (which may
235
+ be split across several `#` lines).
236
+
237
+ getsourcelines starts at `class X:`, so a comment above it falls outside the
238
+ span — meaning a module-level override comment would never round-trip and the
239
+ code_comment lens would re-add/duplicate it. Pulling the comment into the span
240
+ (loaded as the module header) makes add/update/delete stable.
241
+
242
+ Resolves on EVERY call (guarded by the mtime cache), like function_to_address
243
+ — NOT gated on `changed`. Gating let the cached span go stale after another
244
+ edit shifted the class's lines, so the whole-class-span save wrote to the
245
+ wrong range (duplicated/dropped lines, failed delete). A save bumps mtime, so
246
+ the next render re-resolves fresh line numbers."""
247
+ if not isinstance(input_value, type) or input_value.__module__ in ('builtins', '_collections_abc'):
248
+ return changed, None
249
+ try:
250
+ from meltygui.code.libcst_conversion import _parse_override_comment
251
+ source_file = inspect.getfile(input_value)
252
+ FileWatch.register_draw_state(draw_state, Path(source_file))
253
+ try:
254
+ mtime = Path(source_file).stat().st_mtime
255
+ except OSError:
256
+ mtime = None
257
+ cached = getattr(draw_state, '_addr_cache', None)
258
+ if cached is not None and cached[0] is input_value and cached[1] == mtime:
259
+ return changed, cached[2]
260
+
261
+ _evict_linecache(source_file)
262
+ source_lines, start_lineno = inspect.getsourcelines(input_value)
263
+ start0 = start_lineno - 1
264
+ end0 = start0 + len(source_lines)
265
+
266
+ data = Path(source_file).read_bytes()
267
+ newline = _detect_newline(data)
268
+ try:
269
+ file_lines = _split_lines(data.decode("utf-8"))
270
+ except UnicodeDecodeError:
271
+ file_lines = _split_lines(data.decode("latin-1"))
272
+ # Walk up over any contiguous `#` lines above the class, then take the
273
+ # longest tail of that run that parses as ONE override comment - a
274
+ # single `# [...]` line or one split across several `#` lines (each
275
+ # line alone doesn't parse, joined they do). Plain comments above the
276
+ # override stay outside the span.
277
+ ext_start = start0
278
+ j = start0 - 1
279
+ while j >= 0 and file_lines[j].lstrip().startswith("#"):
280
+ j -= 1
281
+ for k in range(j + 1, start0):
282
+ joined = "\n".join(l.strip() for l in file_lines[k:start0])
283
+ if _parse_override_comment(joined) is not None:
284
+ ext_start = k
285
+ break
286
+
287
+ address = Address(Path(source_file), ext_start, end0,
288
+ source=input_value, watcher_ds=draw_state)
289
+ draw_state._addr_cache = (input_value, mtime, address)
290
+ return changed, address
291
+ except (TypeError, OSError, tokenize.TokenError, SyntaxError):
292
+ return changed, None
293
+
294
+
295
+ # ── Cst-dict cache (span parse results) ───────────────────────
296
+ # load_cst_dict's parse (cst.parse_module + cst_module_to_dict, ~230ms for a
297
+ # 2k-line span) is the biggest first-load cost with a few code_file_io views open.
298
+ # Cache one finished GeneralParse per span, with the same shape as the symbol
299
+ # store in libcst_conversion.py:
300
+ # - restart-in-place: adopted through sys._cst_dict_store (sys is shared
301
+ # across re-execs, and across the src./lsd. module-name dupes)
302
+ # - full process restart: pickled to ~/.lsd/cst_dict_cache.pkl on shutdown
303
+ # (FileWatch.shutdown, next to the symbol-cache flush). A stale pickle
304
+ # after a crash just means one slow first parse - acceptable.
305
+ # Entries are pickled BYTES, not live objects: edits mutate a GeneralParse in
306
+ # place, so serving a shared object would alias changes onto one dict and let
307
+ # edited state pose as the disk parse. loads() on a hit (~25ms) hands every
308
+ # consumer a fresh copy, and the blob is dumped BEFORE the Address is stamped -
309
+ # address.source is a live function/class and doesn't pickle by reference; the
310
+ # hit path re-stamps the caller's live Address instead. Invalidation is the
311
+ # file's mtime (cheap, content-free - no hash).
312
+ _CST_DICT_PICKLE = Path.home() / ".lsd" / "cst_dict_cache.pkl"
313
+ _CST_DICT_PICKLE_VERSION = 1
314
+
315
+
316
+ def _load_cst_dict_store() -> dict:
317
+ store = getattr(sys, "_cst_dict_store", None)
318
+ if isinstance(store, dict):
319
+ _ptrace("cst_cache: adopted live store (restart-in-place)",
320
+ entries=len(store.get("entries", ())))
321
+ return store # restart-in-place / module dupe: adopt
322
+ entries = {}
323
+ with _pspan("cst_cache: warm-start load") as _sp:
324
+ try: # fresh process: warm-start from disk
325
+ with open(_CST_DICT_PICKLE, "rb") as f:
326
+ payload = pickle.load(f)
327
+ if payload.get("version") == _CST_DICT_PICKLE_VERSION:
328
+ entries = payload["entries"]
329
+ except Exception as e:
330
+ _sp.add(failed=type(e).__name__) # missing/corrupt → cold start
331
+ _sp.add(entries=len(entries))
332
+ store = {"entries": entries}
333
+ sys._cst_dict_store = store
334
+ return store
335
+
336
+
337
+ # (resolved_path, start, end) -> (mtime, pickled GeneralParse bytes)
338
+ _cst_dict_cache: dict = _load_cst_dict_store()["entries"]
339
+
340
+
341
+ class DiskSpanText(str):
342
+ """A span's text EXACTLY as read from disk, stamped with the file mtime it
343
+ was read at and its span key (TypeCodec.load's plain-disk path). Provenance
344
+ for the cst-dict cache: every string operation (slice, concat, splice)
345
+ returns a plain str, so text still carrying `_disk_mtime` is guaranteed
346
+ pristine disk content — the chain parse cache can trust it with NO content
347
+ comparison, and an edited buffer can never be served a disk-keyed entry.
348
+ Carrying the span key here (rather than relying on `jump_to`) matters: the
349
+ code-host chain only receives jump_to on an Index pulse, so the text itself
350
+ is the only reliable address carrier on the parse path.
351
+
352
+ `_codec` (stamped by TypeCodec.load) carries the codec that loaded this
353
+ text, so a consumer rendering it outside the loader's subtree (the
354
+ open-files editor) re-establishes the codec context — core_render's
355
+ codec block reads it as the last-resort active codec."""
356
+ __slots__ = ("_disk_mtime", "_disk_span", # _disk_span = (realpath, start, end)
357
+ "_codec") # the codec class that loaded this text
358
+
359
+
360
+ def _parser_tag():
361
+ """Which parser a cached gp came from — part of every cst-dict cache key,
362
+ so flipping Toggles.TextEditor.melty_syntax never serves the OTHER
363
+ parser's parse (a libcst gp reverses through libcst, a core_syntax gp
364
+ through its text residual — both work, but the toggle should be seen)."""
365
+ return "meltygui" if Toggles.TextEditor.melty_syntax else "libcst"
366
+
367
+
368
+ def chain_parse_cache_has(span_key, disk_mtime):
369
+ """O(1): would chain_parse_cache_get hit for this pristine buffer? Lets the
370
+ dispatch site inline a first parse that is really just a ~26ms loads —
371
+ skipping the async path's frame-hop tax — while a genuine parse stays on
372
+ the worker."""
373
+ if span_key is None or disk_mtime is None:
374
+ return False
375
+ cached = _cst_dict_cache.get((*span_key, "chain", _parser_tag()))
376
+ return cached is not None and cached[0] == disk_mtime
377
+
378
+
379
+ def chain_parse_cache_get(span_key, disk_mtime):
380
+ """Cached GeneralParse for a PRISTINE disk buffer (see DiskSpanText), or
381
+ None. `span_key` is the text's `_disk_span`. Validity is entry-mtime == the
382
+ mtime the buffer was read at — no stat, no content compare; a fresh copy is
383
+ served per hit (pickle.loads). The gp is served address-less, exactly like
384
+ a live chain parse without jump_to (`file=<no address>`)."""
385
+ key = (*span_key, "chain", _parser_tag())
386
+ cached = _cst_dict_cache.get(key)
387
+ if cached is None or cached[0] != disk_mtime:
388
+ if cached is not None:
389
+ _ptrace("cst_cache: chain miss (mtime)", file=Path(span_key[0]).name)
390
+ return None
391
+ try:
392
+ with _pspan("cst_cache: chain hit loads", file=Path(span_key[0]).name,
393
+ kb=len(cached[1]) // 1024):
394
+ gp = pickle.loads(cached[1])
395
+ except Exception:
396
+ _cst_dict_cache.pop(key, None) # stale class shape etc. → reparse
397
+ return None
398
+ gp.file_path = Path(span_key[0])
399
+ return gp
400
+
401
+
402
+ def chain_parse_cache_put(span_key, disk_mtime, gp):
403
+ """Store a chain-produced GeneralParse (parse of pristine disk text only —
404
+ callers gate on DiskSpanText provenance and a clean parse). The address is
405
+ detached for the dump (live source objects don't pickle) and restored."""
406
+ if not isinstance(gp, dict):
407
+ return
408
+ key = (*span_key, "chain",
409
+ "meltygui" if gp.get("__origin__") is not None else "libcst")
410
+ # Detach what a fresh session re-derives anyway: the live address (source
411
+ # objects don't pickle) and the attached symbol index (~40% of the blob -
412
+ # _ensure_symbol_index re-attaches it from the symbol-usage cache in ~1ms
413
+ # on the first render of a served parse).
414
+ saved_addr = getattr(gp, "address", None)
415
+ saved_sym = getattr(gp, "symbol_usage", None)
416
+ saved_usages = gp.pop("__symbol_usages__", None)
417
+ # The incremental-merge value memo keys on live node ids - meaningless
418
+ # (and sizeable) after an unpickle. The stmt tables STAY: they are plain
419
+ # line data, so a warm-started gp merges incrementally right away.
420
+ saved_memo = getattr(gp, "_value_memo", None)
421
+ try:
422
+ gp.address = None
423
+ gp.symbol_usage = [None]
424
+ if saved_memo is not None:
425
+ gp._value_memo = None
426
+ with _pspan("cst_cache: chain dumps", file=Path(span_key[0]).name, min_ms=5.0):
427
+ blob = pickle.dumps(gp, protocol=pickle.HIGHEST_PROTOCOL)
428
+ except Exception:
429
+ return # unpicklable node → just skip caching
430
+ finally:
431
+ gp.address = saved_addr
432
+ gp.symbol_usage = saved_sym
433
+ if saved_memo is not None:
434
+ gp._value_memo = saved_memo
435
+ if saved_usages is not None:
436
+ gp["__symbol_usages__"] = saved_usages
437
+ _cst_dict_cache[key] = (disk_mtime, blob)
438
+
439
+
440
+ def _cst_cache_key(ref: Address):
441
+ """(key, mtime) for a span Address, or (None, None) when uncacheable."""
442
+ if ref.path is None:
443
+ return None, None
444
+ try:
445
+ resolved = os.path.realpath(str(ref.path))
446
+ mtime = os.stat(resolved).st_mtime
447
+ except OSError:
448
+ return None, None
449
+ return (resolved, ref.start, ref.end), mtime
450
+
451
+
452
+ def _harvest_live_span_parses():
453
+ """Re-cache the LIVE code hosts' held parses under the FINAL disk state.
454
+ chain_parse_cache_put otherwise only fires on pristine-disk loads, so any
455
+ file edited during the session keeps its stale launch-mtime entry and
456
+ cold-parses (~250ms+ for a big span) on every boot. At shutdown — called
457
+ AFTER apply_all_saves flushed pending edits — a clean, error-free held gp
458
+ corresponds to the just-written disk content; re-key it to the current
459
+ mtime so the next launch hits. Any coordinate drift just yields a
460
+ harmless miss (mtime/key won't match), never a wrong serve."""
461
+ try:
462
+ from meltygui.code.new_converters import _code_host_cache
463
+ from meltygui.code.new_converters import host_code_state
464
+ from meltygui.code.new_converters import ModesState
465
+ except Exception:
466
+ return
467
+ stored = 0
468
+ for _key, pair in list(_code_host_cache.items()):
469
+ try:
470
+ sh, dh = pair
471
+ gp = dh._held()
472
+ if not isinstance(gp, dict) or (gp.get("__cst__") is None
473
+ and gp.get("__origin__") is None):
474
+ continue
475
+ cs = host_code_state(sh)
476
+ addr = getattr(cs, "address", None)
477
+ if (addr is None or getattr(addr, "path", None) is None
478
+ or getattr(cs, "_save_refused", False)):
479
+ continue
480
+ # _pending_save is stale here BY TIMING, not by state: the
481
+ # apply_all_saves that runs just before this harvest already wrote
482
+ # the pending edits, but the flag is normally cleared when the file
483
+ # watcher reports the self-write - an event that never gets
484
+ # called during shutdown. Trust the queue instead: only a save
485
+ # that SURVIVED apply_all_saves (SaveConflict, still pending)
486
+ # and disk does not hold this buffer. Skipping on the flag made
487
+ # every actively-edited file miss the cache on every boot - the
488
+ # recurring multi-second cold parse of exactly the file being
489
+ # worked on.
490
+ if getattr(cs, "_pending_save", False):
491
+ from meltygui.editor.pending_save import PendingSave
492
+ _path = str(addr.path)
493
+ if any(str(getattr(a, "path", None)) == _path
494
+ for a in PendingSave.pending_saves):
495
+ continue
496
+ wds = getattr(dh, "_wrapper_draw_state", None)
497
+ ms = next((v for v in (getattr(wds, "misc", None) or {}).values()
498
+ if isinstance(v, ModesState)), None)
499
+ if ms is not None and ms.last_error is not None:
500
+ continue # held/errored gp - predates the buffer
501
+ span_key, mtime = _cst_cache_key(addr)
502
+ if span_key is None:
503
+ continue
504
+ chain_parse_cache_put(span_key, mtime, gp)
505
+ stored += 1
506
+ except Exception:
507
+ continue
508
+ _ptrace("cst_cache: harvested live hosts at shutdown", stored=stored)
509
+
510
+
511
+ def save_cst_dict_cache():
512
+ """Atomic pickle of the span cache. Entries are already address-free bytes,
513
+ so this is a cheap dict-of-bytes dump. Called from FileWatch.shutdown
514
+ (AFTER apply_all_saves — the harvest keys on final disk mtimes)."""
515
+ _harvest_live_span_parses()
516
+ try:
517
+ # Prune stale entries before persisting: a blob whose stored mtime no
518
+ # longer matches its file's current mtime can never hit again (the
519
+ # file changed - and if the span also moved, its replacement lives
520
+ # under a different key). One stat per file path at shutdown.
521
+ file_mtimes = {}
522
+ live = {}
523
+ for key, (entry_mtime, blob) in dict(_cst_dict_cache).items():
524
+ path = key[0]
525
+ if path not in file_mtimes:
526
+ try:
527
+ file_mtimes[path] = os.stat(path).st_mtime
528
+ except OSError:
529
+ file_mtimes[path] = None
530
+ if file_mtimes[path] == entry_mtime:
531
+ live[key] = (entry_mtime, blob)
532
+ with _pspan("cst_cache: save pickle", entries=len(live),
533
+ pruned=len(_cst_dict_cache) - len(live)):
534
+ _CST_DICT_PICKLE.parent.mkdir(parents=True, exist_ok=True)
535
+ tmp = _CST_DICT_PICKLE.with_suffix(".tmp")
536
+ with open(tmp, "wb") as f:
537
+ pickle.dump({"version": _CST_DICT_PICKLE_VERSION,
538
+ "entries": live}, f)
539
+ os.replace(tmp, _CST_DICT_PICKLE)
540
+ except Exception:
541
+ pass
542
+
543
+
544
+ @render_func(background=True)
545
+ def load_cst_module(input_value: Address):
546
+
547
+ file_label = input_value.path.name if input_value.path else "?"
548
+ key, mtime = _cst_cache_key(input_value)
549
+ if key is not None:
550
+ key = (*key, _parser_tag())
551
+ cached = _cst_dict_cache.get(key)
552
+ if cached is not None and cached[0] == mtime:
553
+ try:
554
+ with _pspan("cst_cache: hit loads", file=file_label,
555
+ kb=len(cached[1]) // 1024):
556
+ general_parse = pickle.loads(cached[1])
557
+ general_parse.address = input_value
558
+ general_parse.file_path = input_value.path
559
+ return True, general_parse
560
+ except Exception:
561
+ _cst_dict_cache.pop(key, None) # changed class shape etc. → reparse
562
+ _ptrace("cst_cache: hit blob failed, reparsing", file=file_label)
563
+ else:
564
+ _ptrace("cst_cache: miss", file=file_label,
565
+ reason="no entry" if cached is None else "mtime")
566
+
567
+ with _pspan("cst_cache: parse", file=file_label,
568
+ span=(input_value.start, input_value.end)):
569
+ text = _load_span(input_value)
570
+ if Toggles.TextEditor.melty_syntax:
571
+ general_parse = cst_module_to_dict(text) # core_syntax (str input)
572
+ else:
573
+ general_parse = cst_module_to_dict(cst.parse_module(text))
574
+ general_parse.file_path = input_value.path
575
+ if key is not None:
576
+ try:
577
+ with _pspan("cst_cache: dumps", file=file_label, min_ms=5.0):
578
+ _cst_dict_cache[key] = (mtime, pickle.dumps(
579
+ general_parse, protocol=pickle.HIGHEST_PROTOCOL))
580
+ except Exception:
581
+ pass # unpicklable node → just skip caching
582
+ general_parse.address = input_value
583
+
584
+ if Toggles.slow_down_threads:
585
+ for i in range(5):
586
+ import time
587
+ time.sleep(0.1)
588
+ print(f"Simulating slow load... {i + 1}/5")
589
+
590
+ return True, general_parse
591
+
592
+
593
+ ########################
594
+ # draw_collection
595
+ ########################
596
+
597
+ # Last successful recompile time per Address, for the "fresh" indicator next
598
+ # to the file-load button in address_to_general_parse. Keyed by Address: the
599
+ # same Address object flows through the chain (address → general_parse →
600
+ # address), so the store side (general_parse_to_address) and the display side
601
+ # (address_to_general_parse) agree on the key.
602
+ _last_compile_times: dict = {}
603
+
604
+
605
+ def record_compile(address):
606
+ """Stamp `address` as compiled just now."""
607
+ _last_compile_times[address] = time.time()
608
+
609
+
610
+ def get_compile_time(address):
611
+ """Last compile time for `address`, or None if never compiled this session."""
612
+ return _last_compile_times.get(address)
613
+
614
+
615
+ @render_func(background=False)
616
+ def do_recompile(input_value, code_str, file_path, changed=False):
617
+ """Dispatch recompile to the right handler based on source type."""
618
+ if isinstance(input_value, type):
619
+ _recompile_class(input_value, code_str, str(file_path))
620
+ elif isinstance(input_value, types.FunctionType):
621
+ _recompile(input_value, code_str, str(file_path))
622
+ elif isinstance(input_value, types.ModuleType):
623
+ _recompile_module(input_value, code_str, str(file_path))
624
+ else:
625
+ print(f"Unknown source type {type(input_value).__name__}, skipping recompile")
626
+ return True, None
627
+
628
+
629
+ def _import_stmt_end(lines, i):
630
+ """Index of the LAST line of the (possibly multi-line) import statement that
631
+ starts at line i — following backslash continuations and unclosed parens, so
632
+ callers never split a continued import."""
633
+ stmt = lines[i]
634
+ while i + 1 < len(lines) and (
635
+ stmt.rstrip().endswith("\\") or stmt.count("(") > stmt.count(")")):
636
+ i += 1
637
+ stmt += "\n" + lines[i]
638
+ return i, stmt
639
+
640
+
641
+ def _import_bound_name(stmt):
642
+ """The local name an import statement binds: `import x` → x, `import x as
643
+ y` → y, `from m import x [as y]` → x/y. Best-effort tokens, '' on noise."""
644
+ toks = stmt.replace(",", " ").split()
645
+ if "as" in toks:
646
+ i = toks.index("as")
647
+ return toks[i + 1] if i + 1 < len(toks) else ""
648
+ if toks[:1] == ["from"] and "import" in toks:
649
+ i = toks.index("import")
650
+ return toks[i + 1].split(".")[0] if i + 1 < len(toks) else ""
651
+ if toks[:1] == ["import"] and len(toks) > 1:
652
+ return toks[1].split(".")[0]
653
+ return ""
654
+
655
+
656
+ def _ensure_import_lines(lines, module, name=None):
657
+ """If the import's bound name isn't already imported in `lines`, insert the
658
+ statement after the file's leading import block. Two call shapes:
659
+ (lines, module, name) inserts `from module import name` (the legacy
660
+ @defaults path); (lines, stmt) with name=None inserts the full statement
661
+ verbatim (`import json`, `import numpy as np`, `from x import y` — the
662
+ editor's missing-import quick-fix). Returns
663
+ (lines, inserted_count, insert_idx) — insert_idx is the 0-indexed line the
664
+ import landed on (None when nothing was inserted), so the caller can shift
665
+ co_firstlineno of every code object below it.
666
+
667
+ Best-effort textual scan (no parse — this runs inside the save write). Treats
668
+ the run of leading import/comment/blank/docstring lines as the import block
669
+ and inserts after it. Continuation-aware (backslash + parens) so it never
670
+ inserts in the middle of a multi-line import."""
671
+ if name is None:
672
+ stmt_text = module
673
+ name = _import_bound_name(stmt_text)
674
+ if not name:
675
+ return lines, 0, None
676
+ else:
677
+ stmt_text = f"from {module} import {name}"
678
+ # ── Dedup: is `name` already imported? (join continuations before checking) ──
679
+ i = 0
680
+ while i < len(lines):
681
+ s = lines[i].strip()
682
+ if s.startswith("import ") or s.startswith("from "):
683
+ end, stmt = _import_stmt_end(lines, i)
684
+ syms = stmt.split("import", 1)[1] if "import" in stmt else ""
685
+ for ch in "(),\\\n":
686
+ syms = syms.replace(ch, " ")
687
+ if name in [t.split(".")[0] for t in syms.split()]:
688
+ return lines, 0, None
689
+ i = end + 1
690
+ continue
691
+ i += 1
692
+
693
+ # ── Find the insert point: end of the leading import block ──────────────────
694
+ insert_idx = 0
695
+ i = 0
696
+ in_doc = None # triple-quote delimiter while inside a module docstring
697
+ seen_code = False
698
+ while i < len(lines):
699
+ s = lines[i].strip()
700
+ if in_doc is not None: # inside a multi-line docstring
701
+ insert_idx = i + 1
702
+ if in_doc in s:
703
+ in_doc = None
704
+ i += 1
705
+ continue
706
+ if s == "" or s.startswith("#"):
707
+ i += 1
708
+ continue
709
+ if not seen_code and (s.startswith('"""') or s.startswith("'''")):
710
+ q = s[:3]
711
+ seen_code = True
712
+ insert_idx = i + 1
713
+ if not (len(s) > 3 and s.count(q) >= 2): # not a one-line docstring
714
+ in_doc = q
715
+ i += 1
716
+ continue
717
+ if s.startswith("import ") or s.startswith("from "):
718
+ seen_code = True
719
+ end, _ = _import_stmt_end(lines, i) # skip past continuations
720
+ insert_idx = end + 1
721
+ i = end + 1
722
+ continue
723
+ break # first real code - stop scanning the import block
724
+ new_lines = list(lines)
725
+ new_lines.insert(insert_idx, stmt_text)
726
+ return new_lines, 1, insert_idx
727
+
728
+
729
+ @render_func(background=True)
730
+ def _do_save(input_value, code_str, ensure_import=None):
731
+ """Queue the edited span into the PendingSave cache — the deferred write.
732
+
733
+ Was a synchronous disk splice + sibling-lineno shift. Now every chain save
734
+ routes here and DEFERS to PendingSave exactly like code_file_io: the
735
+ structured editor's Save button / Ctrl+S, every code lens (class_var /
736
+ decoration / code_comment all end their chain in general_parse_to_address),
737
+ and the caller node. The splice/write/sibling-shift happen once at
738
+ apply_all_saves, through the codec — which also performs the ensure_import
739
+ insert and the span-conflict guard that this used to do inline.
740
+
741
+ The codec is the SPAN-replacement codec for the address's live source
742
+ (class / function / module) — never CallerCodec, since the chain hands us a
743
+ full-span code_str, not a bare call expression. TypeCodec (plain span splice)
744
+ is the fallback when the source type isn't separately registered."""
745
+ from meltygui.editor.pending_save import PendingSave
746
+ from meltygui.code.new_codecs import type_to_codec
747
+ from meltygui.code.new_codecs import TypeCodec
748
+ source = input_value.source
749
+ codec = next((type_to_codec[k] for k in type(source).__mro__ if k in type_to_codec),
750
+ TypeCodec) if source is not None else TypeCodec
751
+ PendingSave.queue_save(input_value, codec, data=code_str, ensure_import=ensure_import)
752
+ return True, input_value
753
+
754
+
755
+ @render_func(background=True)
756
+ def dict_to_cst(input_value, changed=False):
757
+ back_to_cst = dict_to_cst_module(input_value)
758
+ if Toggles.slow_down_threads:
759
+ for i in range(5):
760
+ import time
761
+ time.sleep(0.1)
762
+ print(f"Simulating slow load... {i + 1}/5")
763
+
764
+ return False, back_to_cst
765
+
766
+
767
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
768
+ # ║ Live apply - cst_dict edits drive the live object ahead of save/recompile ║
769
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
770
+ # The responsive-preview layer in front of the real hotswap: when an edit flows
771
+ # back through a chain (general_parse_to_address) down the cache route
772
+ # (draw_code_tabs_from_cache), plain values land on the LIVE object immediately.
773
+ # The recompile (Ctrl+Enter / Run) still owns code, new names, and source truth.
774
+
775
+ def _usable_parse_entry(k, v):
776
+ """A parsed entry that can land on a live object as-is: a real identifier
777
+ key (Comment keys aren't), public, and a plain VALUE — not a nested parse
778
+ dict and not a CodeLine (a str SUBCLASS holding unparsed source text, not
779
+ the value)."""
780
+ if not isinstance(k, str) or not k.isidentifier() or k.startswith("_"):
781
+ return False
782
+ return not isinstance(v, (dict, CodeLine))
783
+
784
+
785
+ def _differs(cur, v):
786
+ try:
787
+ return not (cur is v or cur == v)
788
+ except Exception:
789
+ return True # incomparable (e.g. ndarray) - treat as different
790
+
791
+
792
+ def _is_funcdef_parse(v):
793
+ """A nested funcdef parse (method / function child dict).
794
+
795
+ Now a FunctionParse by TYPE (cst_funcdef_to_dict emits one). The 'parameters'/
796
+ 'locals' key heuristic is kept as a fallback for any GeneralParse not produced
797
+ by that converter — CallParse holds call KWARGS under the same dict shape, so
798
+ it stays explicitly excluded from the heuristic branch."""
799
+ return isinstance(v, FunctionParse) or (
800
+ isinstance(v, GeneralParse) and not isinstance(v, CallParse)
801
+ and ("parameters" in v or "locals" in v))
802
+
803
+
804
+ def _is_classdef_parse(v):
805
+ """A nested classdef parse (a class child dict, e.g. Toggles.InvalidateTracker).
806
+
807
+ Now a ClassParse by TYPE (cst_classdef_to_dict emits one). The __cst__ ClassDef
808
+ check is kept as a fallback for any GeneralParse not produced by that converter.
809
+ Either way this is precise where the funcdef key heuristic is not, so it's
810
+ checked FIRST by callers."""
811
+ return isinstance(v, ClassParse) or (
812
+ isinstance(v, GeneralParse) and isinstance(v.get("__cst__"), cst.ClassDef))
813
+
814
+
815
+ def _raw_function(obj):
816
+ """The plain FunctionType behind a member: through decorator wrappers
817
+ (inspect.unwrap), static/classmethod descriptors, and bound methods.
818
+ None when there's no real function (a property, a non-callable, ...)."""
819
+ if isinstance(obj, (staticmethod, classmethod)):
820
+ obj = obj.__func__
821
+ if inspect.ismethod(obj):
822
+ obj = obj.__func__
823
+ try:
824
+ obj = inspect.unwrap(obj)
825
+ except Exception:
826
+ return None
827
+ return obj if isinstance(obj, types.FunctionType) else None
828
+
829
+
830
+ def _apply_param_defaults(raw, params) -> bool:
831
+ """Land edited parameter defaults on the live function's __defaults__ /
832
+ __kwdefaults__ — the same members the real hotswap patches, so a later
833
+ recompile simply re-asserts them. The parse maps every param name (NO_DEFAULT
834
+ for default-less ones); only names that exist in the live default sets are
835
+ touched, so an added/removed parameter still needs the recompile."""
836
+ try:
837
+ sig = inspect.signature(raw)
838
+ except (TypeError, ValueError):
839
+ return False
840
+ applied = False
841
+
842
+ pos = [p for p in sig.parameters.values()
843
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
844
+ defaults = list(raw.__defaults__ or ())
845
+ offset = len(pos) - len(defaults)
846
+ if offset >= 0:
847
+ changed_pos = False
848
+ for i, p in enumerate(pos[offset:]):
849
+ v = params.get(p.name, NO_DEFAULT)
850
+ if isinstance(v, type(NO_DEFAULT)) or not _usable_parse_entry(p.name, v):
851
+ continue
852
+ if _differs(defaults[i], v):
853
+ defaults[i] = v
854
+ changed_pos = True
855
+ if changed_pos:
856
+ raw.__defaults__ = tuple(defaults)
857
+ applied = True
858
+
859
+ kwd = raw.__kwdefaults__
860
+ if kwd:
861
+ new_kwd = dict(kwd)
862
+ changed_kw = False
863
+ for name, cur in kwd.items():
864
+ v = params.get(name, NO_DEFAULT)
865
+ if isinstance(v, type(NO_DEFAULT)) or not _usable_parse_entry(name, v):
866
+ continue
867
+ if _differs(cur, v):
868
+ new_kwd[name] = v
869
+ changed_kw = True
870
+ if changed_kw:
871
+ raw.__kwdefaults__ = new_kwd
872
+ applied = True
873
+ return applied
874
+
875
+
876
+ # Instructions that read a co_consts slot (3.12): a slot referenced by more
877
+ # than one of these is shared (the compiler dedups equal literals) and is
878
+ # never patched.
879
+ _CONST_REF_OPS = {"LOAD_CONST", "RETURN_CONST", "KW_NAMES"}
880
+
881
+
882
+ def _const_like(v):
883
+ if isinstance(v, (int, float, complex, str, bytes, bool, type(None))):
884
+ return True
885
+ if isinstance(v, (tuple, frozenset)):
886
+ return all(_const_like(x) for x in v)
887
+ return False
888
+
889
+
890
+ def _apply_const_locals(raw, locals_) -> bool:
891
+ """Patch `name = <literal>` body assignments straight into co_consts via
892
+ code.replace — no compile, the code object's identity lineage stays intact.
893
+
894
+ Conservative by construction; a swap happens ONLY when the binding is
895
+ unambiguous in the bytecode:
896
+ • the name is stored exactly once in the function (a reassigned name —
897
+ `x` + `x#1` keys — or a computed value drops out), and
898
+ • that store is fed directly by LOAD_CONST, and
899
+ • the const slot is referenced by exactly one instruction in the whole
900
+ code object — the compiler dedups equal literals, so `x = 2.0` and
901
+ `foo(2.0)` can share a slot, and patching it would silently change
902
+ the other use.
903
+ Everything ambiguous waits for the real recompile."""
904
+ import dis
905
+ code = raw.__code__
906
+ store_idx = {} # local name -> co_consts index (None = multi/computed)
907
+ ref_counts = {} # co_consts index -> referencing-instruction count
908
+ prev = None
909
+ try:
910
+ for ins in dis.get_instructions(code):
911
+ if ins.opname in _CONST_REF_OPS and ins.arg is not None:
912
+ ref_counts[ins.arg] = ref_counts.get(ins.arg, 0) + 1
913
+ if ins.opname == "STORE_FAST":
914
+ if ins.argval in store_idx:
915
+ store_idx[ins.argval] = None
916
+ elif prev is not None and prev.opname == "LOAD_CONST":
917
+ store_idx[ins.argval] = prev.arg
918
+ else:
919
+ store_idx[ins.argval] = None
920
+ prev = ins
921
+ except Exception:
922
+ return False
923
+
924
+ consts = list(code.co_consts)
925
+ changed = False
926
+ for k, v in locals_.items():
927
+ if not _usable_parse_entry(k, v) or not _const_like(v):
928
+ continue
929
+ idx = store_idx.get(k)
930
+ if idx is None or ref_counts.get(idx, 0) != 1:
931
+ continue
932
+ if not _differs(consts[idx], v):
933
+ continue
934
+ consts[idx] = v
935
+ changed = True
936
+ if changed:
937
+ try:
938
+ raw.__code__ = code.replace(co_consts=tuple(consts))
939
+ except Exception:
940
+ return False
941
+ return changed
942
+
943
+
944
+ def _apply_function_parse(fn, parsed) -> bool:
945
+ """Live-apply a funcdef parse: parameter defaults + constant locals."""
946
+ raw = _raw_function(fn)
947
+ if raw is None or not isinstance(parsed, dict):
948
+ return False
949
+ applied = False
950
+ params = parsed.get("parameters")
951
+ if isinstance(params, dict):
952
+ applied |= _apply_param_defaults(raw, params)
953
+ locals_ = parsed.get("locals")
954
+ if isinstance(locals_, dict):
955
+ applied |= _apply_const_locals(raw, locals_)
956
+ return applied
957
+
958
+
959
+ def _apply_class_parse(cls, parsed) -> bool:
960
+ """Live-apply a classdef parse: plain class vars via setattr, method child
961
+ dicts via the function path (param defaults + constant locals), and NESTED
962
+ classes by RECURSING onto the live nested class.
963
+
964
+ The recursion is what lets an edit to a class nested inside `cls` (e.g.
965
+ Toggles.InvalidateTracker.invalidate_stack_trace) land on the live object in
966
+ place — its plain vars and method defaults apply immediately, the same
967
+ responsive preview the top-level class gets, instead of waiting for the full
968
+ recompile. Structural changes (new/removed members) still need the recompile,
969
+ exactly as at the top level.
970
+
971
+ `__init__` self-assignments surface as fields too but fail the hasattr
972
+ check (instance attrs, not class vars); decorators / CallParse stay nested
973
+ dicts and are skipped."""
974
+ applied = False
975
+ for k, v in parsed.items():
976
+ if not isinstance(k, str) or not k.isidentifier() or k.startswith("_"):
977
+ continue
978
+ # Nested class FIRST: a classdef parse carries an unambiguous cst.ClassDef
979
+ # marker, whereas _is_funcdef_parse is a key heuristic that a class member
980
+ # literally named `parameters`/`locals` would trip.
981
+ if _is_classdef_parse(v):
982
+ member = inspect.getattr_static(cls, k, None)
983
+ if isinstance(member, type) and _apply_class_parse(member, v):
984
+ applied = True
985
+ # The outer invalidate (live_apply_edits) keys off `cls`, so it
986
+ # never reaches a tile drawn from the nested class - trigger it
987
+ # here, the same way live_apply_edits repaints its outer source.
988
+ if Melty.cache is not None:
989
+ Melty.cache.invalidate_up_by_obj(member, max_depth=10)
990
+ continue
991
+ if _is_funcdef_parse(v):
992
+ member = inspect.getattr_static(cls, k, None)
993
+ if member is not None:
994
+ applied |= _apply_function_parse(member, v)
995
+ continue
996
+ if isinstance(v, (dict, CodeLine)):
997
+ continue
998
+ if not hasattr(cls, k):
999
+ continue
1000
+ try:
1001
+ cur = getattr(cls, k)
1002
+ if cur is v or cur == v:
1003
+ continue
1004
+ except Exception:
1005
+ pass # incomparable (e.g. ndarray) - fall through and set
1006
+ try:
1007
+ setattr(cls, k, v)
1008
+ applied = True
1009
+ except Exception:
1010
+ pass
1011
+ return applied
1012
+
1013
+
1014
+ def _apply_module_parse(mod, parsed) -> bool:
1015
+ """Live-apply a module parse: dispatch each top-level entry on the LIVE
1016
+ object — classes through the class path, functions through the function
1017
+ path, plain existing globals via setattr."""
1018
+ applied = False
1019
+ for k, v in parsed.items():
1020
+ if not isinstance(k, str) or not k.isidentifier() or k.startswith("_"):
1021
+ continue
1022
+ if k not in mod.__dict__:
1023
+ continue
1024
+ live = mod.__dict__[k]
1025
+ if isinstance(v, GeneralParse) and not isinstance(v, CallParse):
1026
+ if isinstance(live, type):
1027
+ applied |= _apply_class_parse(live, v)
1028
+ elif _is_funcdef_parse(v):
1029
+ applied |= _apply_function_parse(live, v)
1030
+ continue
1031
+ if isinstance(v, (dict, CodeLine)):
1032
+ continue
1033
+ try:
1034
+ if not _differs(live, v):
1035
+ continue
1036
+ setattr(mod, k, v)
1037
+ applied = True
1038
+ except Exception:
1039
+ pass
1040
+ return applied
1041
+
1042
+
1043
+ @lag_traced("live_apply_edits", 50)
1044
+ def live_apply_edits(source, gp) -> None:
1045
+ """Entry point: drive the LIVE source object from its edited cst_dict.
1046
+
1047
+ `gp` is the module-shaped parse of the edited span — a class/function span
1048
+ keys its parse under __name__; a module span IS the parse. Unknown source
1049
+ types (CallSite, Decorations, None) no-op. On apply, repaint cached views
1050
+ drawn from the source (same invalidation the real recompile does)."""
1051
+ if not isinstance(gp, dict):
1052
+ return
1053
+ applied = False
1054
+ if isinstance(source, type):
1055
+ inner = gp.get(source.__name__)
1056
+ if isinstance(inner, dict):
1057
+ applied = _apply_class_parse(source, inner)
1058
+ elif isinstance(source, types.FunctionType):
1059
+ inner = gp.get(source.__name__)
1060
+ if isinstance(inner, dict):
1061
+ applied = _apply_function_parse(source, inner)
1062
+ elif isinstance(source, types.ModuleType):
1063
+ applied = _apply_module_parse(source, gp)
1064
+ if applied and Melty.cache is not None:
1065
+ if isinstance(source, types.FunctionType):
1066
+ Melty.cache.invalidate_up_by_func(source, max_depth=10)
1067
+ else:
1068
+ Melty.cache.invalidate_up_by_obj(source, max_depth=10)
1069
+
1070
+
1071
+ def _live_apply_class_vars(cls: type, gp: dict) -> None:
1072
+ """Back-compat alias — the class entry of live_apply_edits."""
1073
+ live_apply_edits(cls, gp)
1074
+
1075
+
1076
+ from meltygui.view.code_view import run_button
1077
+
1078
+
1079
+ @render_func(use_cache=True, selectable=False)
1080
+ def address_to_general_parse(input_value: Address, pending=False, unique=None, changed=False, draw_state=None, auto_load=True, load=False):
1081
+ """Load node: class → cst.Module.
1082
+
1083
+ - Resolves Address from the class on first call
1084
+ - Watches file mtime for external changes
1085
+ - Shows Load / Revert buttons when file changes on disk
1086
+ - Returns (True, cst.Module) when loaded, (False, cached) otherwise
1087
+ """
1088
+ if draw_state.frame_count < 2 and auto_load:
1089
+ load = True
1090
+
1091
+ from meltygui.view.control_view import button
1092
+ file_name = input_value.path.name if input_value.path is not None else "Unknown file"
1093
+ folder_icon = ""
1094
+ # if button(f"{folder_icon} {file_name}", height=30, value=0.4, saturation=1.5)[0]:
1095
+ # from meltygui.utils.jump_to_code import open_in_intellij
1096
+ # line_number = input_value.start + 1 if input_value.start is not None else None
1097
+ # threading.Thread(
1098
+ # target=open_in_intellij,
1099
+ # args=(str(input_value.path),),
1100
+ # kwargs={"line_number": line_number},
1101
+ # daemon=True,
1102
+ # ).start()
1103
+
1104
+ # Last-compiled indicator: shows the wall-clock time of the most recent
1105
+ # recompile for this file (Ctrl+Enter or the do_recompile button).
1106
+ compile_time = get_compile_time(input_value)
1107
+ if compile_time is not None:
1108
+ check_icon = ""
1109
+ imgui.same_line()
1110
+ imgui.align_text_to_frame_padding()
1111
+ imgui.text_colored(f"{check_icon} compiled {time.strftime('%H:%M:%S', time.localtime(compile_time))}",
1112
+ 0.55, 0.8, 0.55, 1.0)
1113
+
1114
+ if pending or changed:
1115
+ clicked, result = run_button(load_cst_module, with_kwargs={"input_value": input_value},
1116
+ clicked=load, name=f"load_cst_module{unique}")
1117
+ if clicked:
1118
+ return result
1119
+
1120
+ # ── Steady state ──────────────────────────────────────────
1121
+ return False, None
1122
+
1123
+ @render_func(use_cache=True, selectable=False)
1124
+ def general_parse_to_address(input_value: GeneralParse=None, pending=False, draw_state=None, unique=None,
1125
+ changed=False, recompile=False, save=False, s_key_pressed=None,
1126
+ enter_key_pressed=None, ensure_import=None):
1127
+ """GeneralParse dict → Address. Handles recompile and save for any source type.
1128
+
1129
+ ensure_import=(module, name) is forwarded to _do_save so a synthesized
1130
+ decorator (e.g. @defaults) gets its import inserted in the same write."""
1131
+ address = input_value.address
1132
+ if not isinstance(address, Address):
1133
+ imgui.text_colored("Saving unavailable...\ninput_value.address is not set",
1134
+ 1.0, 0.0, 0.0)
1135
+ return False, None
1136
+ source = address.source
1137
+
1138
+ # Edits drive the live object immediately (live preview): class vars,
1139
+ # function arg defaults, constant locals, module globals. The
1140
+ # recompile/hotswap below still owns code, new names, and source truth.
1141
+ if changed and source is not None:
1142
+ live_apply_edits(source, input_value)
1143
+
1144
+ # Latch the save intent across the background dict_to_cst latency. An edit
1145
+ # (focus Add/Delete, a single color pick) sets changed=True for ONE frame -
1146
+ # which lands exactly on dict_to_cst's Pending and is gone by the time the
1147
+ # conversion completes, so the save was just dropped. Hold the intent until
1148
+ # _do_save actually completes, then clear it.
1149
+ if changed:
1150
+ draw_state._lens_save_pending = True
1151
+ save_pending = getattr(draw_state, '_lens_save_pending', False)
1152
+
1153
+ # Ctrl+Enter fires HERE (before this frame's dict→Cst conversion while the Run
1154
+ # button fires below (after it). They used to read different sources - the
1155
+ # stale loaded `input_value.source` vs the freshly materialized `code_str` -
1156
+ # so Ctrl+Enter hotswapped the on-disk version. Unify them on ONE source: the
1157
+ # PendingSave cache. The materialized edit is written through to it below, and
1158
+ # every trigger reads from it via `_edited_source`, falling back to the passed
1159
+ # value only when the cache is cold (never edited / just loaded).
1160
+ from meltygui.editor.pending_save import PendingSave
1161
+ from meltygui.code.new_codecs import type_to_codec
1162
+ codec = next((type_to_codec[k] for k in type(source).__mro__ if k in type_to_codec), None) \
1163
+ if source is not None else None
1164
+
1165
+ def _edited_source(fallback):
1166
+ cached = PendingSave.pending_text_for(address)
1167
+ return cached if cached is not None else fallback
1168
+
1169
+ show_recompile = True
1170
+ show_save = not save or pending or changed or save_pending
1171
+
1172
+ save_hotkey = s_key_pressed and s_key_pressed.ctrl
1173
+ if save_hotkey:
1174
+ _do_save(address, code_str=_edited_source(input_value.source))
1175
+ Melty.cache.invalidate_up(draw_state.parent_window._tile_id, max_depth=10)
1176
+
1177
+ # Ctrl+Enter: hotswap the edited code without writing to disk. Mirrors the
1178
+ # Ctrl+S save hotkey above, but routes through do_recompile instead.
1179
+ recompile_hotkey = enter_key_pressed and enter_key_pressed.ctrl
1180
+ if recompile_hotkey and source is not None:
1181
+ do_recompile(input_value=source, code_str=_edited_source(input_value.source), file_path=address.path)
1182
+ record_compile(address)
1183
+ Melty.cache.invalidate_up(draw_state.parent_window._tile_id, max_depth=10)
1184
+
1185
+ # Skip expensive dict→CST conversion when neither block will execute
1186
+ if not show_recompile and not show_save:
1187
+ return False, address
1188
+
1189
+ convert_finished, back_to_cst = dict_to_cst(input_value=input_value, changed=changed)
1190
+ if isinstance(back_to_cst, Pending):
1191
+ if back_to_cst.state == PendingState.ERROR:
1192
+ # Conversion failed (e.g. unparseable edit) - give up the latch so it
1193
+ # doesn't spin requesting renders forever.
1194
+ draw_state._lens_save_pending = False
1195
+ elif save_pending:
1196
+ # Still converting. Keep the latch + keep frames coming so the save
1197
+ # fires the moment the (background) code_str lands.
1198
+ request_render()
1199
+ return False, back_to_cst
1200
+ code_str = back_to_cst.code
1201
+ # Write the freshly materialized edit through to the PendingSave cache so the
1202
+ # next Ctrl+Enter / Run / load all read THIS source instead of disk. Only while
1203
+ # an edit is active (never from plain view), keyed by address so it refreshes the
1204
+ # entry in place. _do_save below still writes to disk this frame, so the
1205
+ # eventual apply_all_saves flush of this entry is an idempotent no-op.
1206
+ if codec is not None and (changed or save_pending):
1207
+ PendingSave.queue_save(address, codec, data=code_str, ensure_import=ensure_import)
1208
+ if show_recompile:
1209
+ if source is not None:
1210
+ from meltygui.core.rendering.mode import Mode
1211
+ recompiled, _ = run_button(do_recompile, clicked=recompile and pending, name=f"do_recompile{unique}",
1212
+ with_kwargs={"input_value": address.source,
1213
+ "code_str": _edited_source(code_str),
1214
+ "file_path": address.path}, layer_offset=1, pin_to_clip=Pin.CLIP,
1215
+ parent_anchor=Anchor.BOTTOM_LEFT,
1216
+ tint=(0.3, 0.4, 0.6))
1217
+ if recompiled:
1218
+ record_compile(address)
1219
+ # Refresh the cached file path node so its compiled indicator updates.
1220
+ Melty.cache.invalidate_up(draw_state.parent_window._tile_id, max_depth=10)
1221
+
1222
+ imgui.dummy(1,1)
1223
+ if show_save:
1224
+ if source is not None:
1225
+ clicked, result = run_button(_do_save, with_kwargs={"input_value": address,
1226
+ "code_str": code_str,
1227
+ "ensure_import": ensure_import},
1228
+ clicked=save or save_pending)
1229
+ # We reached a real code_str and dispatched the write to the background
1230
+ # thread, which writes regardless of further pumping. Clear the latch
1231
+ # on dispatch (not on completion) so an errored/never-completing save
1232
+ # can't spin the latch forever; Background invalidates on completion.
1233
+ draw_state._lens_save_pending = False
1234
+ if clicked:
1235
+ if draw_state.parent_window is not None:
1236
+ Melty.cache.invalidate_up(draw_state.parent_window._tile_id, max_depth=10)
1237
+ return True, address
1238
+ return False, address
1239
+
1240
+
1241
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
1242
+ # ║ focus: a reversible lens node ║
1243
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
1244
+
1245
+ def _focus_get(obj, key):
1246
+ return obj.get(key) if isinstance(obj, dict) else getattr(obj, key, None)
1247
+
1248
+
1249
+ def _focus_set(obj, key, value):
1250
+ if isinstance(obj, dict):
1251
+ obj[key] = value
1252
+ else:
1253
+ setattr(obj, key, value)
1254
+
1255
+
1256
+ @render_func(use_cache=False, show_bg=False, selectable=False, is_tree=False, with_header=draw_header)
1257
+ def focus(input_value, path=(), default=None, kind=None, draw_state=None, unique=None, changed=False, **kwargs):
1258
+ """Descend a STATIC key `path` into `input_value` to a single leaf, render
1259
+ that leaf with its normal renderer (draw_tuple, for a tint), and write any
1260
+ edit back into the container *in place*.
1261
+
1262
+ This is the one primitive that turns the existing reversible converter pairs
1263
+ (class_to_address ↔ address_to_class, address_to_general_parse ↔
1264
+ general_parse_to_address) into full read/write lenses: drop `focus` where
1265
+ `draw_collection` would sit in a chain and it edits just the focused leaf.
1266
+
1267
+ Contract — identical to every other chain node: returns (changed, value).
1268
+ - read : leaf exists → render the picker; (False, input_value) until edited.
1269
+ - write : on a picker edit, mutate container[path] and return (True, input_value)
1270
+ so a downstream save node runs once.
1271
+ - add : leaf missing → show a "+ Add" button; clicking creates the leaf
1272
+ (and any missing intermediate dicts) with `default` and returns
1273
+ (True, input_value), so the same save node persists the new value
1274
+ (e.g. writes a fresh `# [tint=(...)]` comment for a code source).
1275
+
1276
+ `path` is a constant supplied by the lens definition — it is NEVER stored in
1277
+ draw_state (which is GC'd on a short TTL). The only transient thing is
1278
+ `input_value`, the container reference flowing through the chain; it lives for
1279
+ this frame only. The reverse direction works because we keep the parent
1280
+ container in the value — the same move Address makes by carrying `.source`.
1281
+
1282
+ Handles dict-key and attribute access uniformly, so the same node focuses aj
1283
+ GeneralParse dict (code-comment / decoration tint), a draw_state, or a data
1284
+ class instance.
1285
+ """
1286
+ from meltygui.view.collection_view import draw_tuple
1287
+ from meltygui.view.control_view import button
1288
+
1289
+ changed, new_value = False, input_value
1290
+ if not path:
1291
+ imgui.text_colored("focus: empty path", 1.0, 0.4, 0.0)
1292
+ return False, input_value
1293
+
1294
+ leaf_key = path[-1]
1295
+
1296
+ # Walk to the leaf's parent; note where (if anywhere) the chain breaks so the
1297
+ # add path knows it many containers to create.
1298
+ parent = input_value
1299
+ reachable = True
1300
+ for key in path[:-1]:
1301
+ nxt = _focus_get(parent, key)
1302
+ if nxt is None:
1303
+ reachable = False
1304
+ break
1305
+ parent = nxt
1306
+
1307
+ leaf = _focus_get(parent, leaf_key) if reachable else None
1308
+
1309
+ # Clean label: the lens name + a plain target name - the class for code
1310
+ # lenses, the enclosing fn for the caller, the data class for an instance.
1311
+ # No internal keys (__overrides__ etc.); the "where" lives in the jump button.
1312
+ if len(path) > 1:
1313
+ target = str(path[0]) # class name (code lenses)
1314
+ elif isinstance(input_value, dict):
1315
+ _a = input_value.get("__address__")
1316
+ target = getattr(getattr(_a, "source", None), "__name__", None) \
1317
+ if isinstance(_a, Address) else None # caller's enclosing fn
1318
+ elif input_value is not None and type(input_value).__name__ != "DrawState":
1319
+ target = type(input_value).__name__ # data instance class
1320
+ else:
1321
+ target = None
1322
+ base = kind or str(draw_state.name)
1323
+ label = f"{base} · {target}" if target else base
1324
+
1325
+ imgui.same_line()
1326
+ imgui.text(label)
1327
+
1328
+ # Code-jump button - open the source where this lens's value lives. The call
1329
+ # dict carries __address__; a GeneralParse carries .address. Live other values
1330
+ # (draw_state / instance) have no source location, so no button is shown.
1331
+ _addr = input_value.get("__address__") if isinstance(input_value, dict) else None
1332
+ if _addr is None:
1333
+ _addr = getattr(input_value, "address", None)
1334
+
1335
+ if isinstance(_addr, Address) and _addr.path is not None:
1336
+ _line = (_addr.start or 0) + 1
1337
+ imgui.same_line()
1338
+ if button(f"{_addr.path.name}:{_line}##{unique}", height=24, name=f"jump{base}{leaf_key}{unique}")[0]:
1339
+ import threading
1340
+ from meltygui.utils.jump_to_code import open_in_intellij
1341
+ threading.Thread(target=open_in_intellij, args=(str(_addr.path),),
1342
+ kwargs={"line_number": _line}, daemon=True).start()
1343
+
1344
+ # ── Present: the reusable picker (every lens funnels through this) ─────────
1345
+ if leaf is not None:
1346
+ tint_changed, new_tint = draw_tuple(leaf, with_header=draw_header, name=f"{unique}{leaf_key}_tuple_{unique}")
1347
+ if tint_changed:
1348
+ _focus_set(parent, leaf_key, new_tint)
1349
+ changed, new_value = True, input_value # hand the mutated container to the save node
1350
+ # Delete: drop this source's override so it stops winning. For a dict
1351
+ # (code/caller) the key is popped → dict_to_cst() removes it from source on
1352
+ # save; for a live attr it's set to None. Return changed so the save side
1353
+ # persists the removal, same as an edit.
1354
+ imgui.same_line()
1355
+
1356
+ delete_icon = ""
1357
+ if button(f"{delete_icon}##{unique}", name=f"{leaf_key}_delete_{unique}", height=26)[0]:
1358
+ if isinstance(parent, dict):
1359
+ parent.pop(leaf_key, None)
1360
+ else:
1361
+ _focus_set(parent, leaf_key, None)
1362
+ changed, new_value = True, input_value
1363
+ else:
1364
+
1365
+ # ── Not present: offer to add it ───────────────────────────────────────────────
1366
+ if button(f"##{leaf_key}{unique}", name=f"{leaf_key}_add_{unique}", height=26)[0]:
1367
+ node = input_value
1368
+ for key in path[:-1]: # create missing intermediate containers
1369
+ child = _focus_get(node, key)
1370
+ if child is None:
1371
+ child = {}
1372
+ _focus_set(node, key, child)
1373
+ node = child
1374
+ try:
1375
+ _focus_set(node, leaf_key, default if default is not None else (0.485, 0.61, 0.76))
1376
+ changed, new_value = True, input_value
1377
+
1378
+ except Exception as e:
1379
+ print_stack_trace(exception=e)
1380
+ print("Error setting value in focus node:", e)
1381
+ changed, new_value = False, input_value
1382
+
1383
+ return changed, new_value
1384
+
1385
+
1386
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
1387
+ # ║ Caller kwarg lens nodes: edit a kwarg literal at the call site ║
1388
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
1389
+ #
1390
+ # These let the context menu edit `draw_text(..., tint=(1,0,1))` by parsing the
1391
+ # CALLER's statement. The call site comes from draw_state._call_site - the
1392
+ # (filename, lineno) resolved once (via caller_site) when frames were grabbed on
1393
+ # menu-open in core_render; never re-walked from the live stack. Reuses the
1394
+ # existing cst_call_to_dict / dict_to_cst_call converters (the same ones that
1395
+ # parse @decorator(...) calls).
1396
+
1397
+ _DISPATCH_SKIP = ("core_render.py",) # the render_func wrapper lives here
1398
+
1399
+
1400
+ def _is_dispatch_frame(filename, func_name):
1401
+ """True if a frame is render-dispatch machinery to skip when resolving call
1402
+ sites — the render_func wrapper, Melty.draw / draw_any re-dispatch, or a
1403
+ user-configured shell in Toggles.ignore_call_from (matched by func name, any
1404
+ file). Shared by caller_site and caller_sites so both filter identically."""
1405
+ base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
1406
+ if base in _DISPATCH_SKIP: # render_func wrapper
1407
+ return True
1408
+ # Func-name-specific (NOT whole file): new_core_view.py / meltygui.py also hold
1409
+ # real user app code, so only their dispatch frames are skipped.
1410
+ if base == "meltygui.py" and func_name == "draw": # Melty.draw re-dispatch
1411
+ return True
1412
+ if base == "new_core_view.py" and func_name == "draw_any": # draw_any dispatch
1413
+ return True
1414
+ # User-configurable wrapper/dispatch shells (RenderFuncs._draw, the user_*
1415
+ # render shells).
1416
+ if func_name in Toggles.ignore_call_from:
1417
+ return True
1418
+ return False
1419
+
1420
+
1421
+ def caller_sites(frames):
1422
+ """Every real caller (filename, lineno), innermost-first, with the render-
1423
+ dispatch machinery and Toggles.ignore_call_from shells filtered out — the
1424
+ chain of user draw_x(...) calls that produced this view, from the nearest
1425
+ caller outward. draw_context_menu draws one code_file_io per entry.
1426
+
1427
+ Returning just (filename, lineno) tuples is critical: the frames list carries
1428
+ each frame's f_locals (the whole AppModel, tensors, cyclic refs). Feeding that
1429
+ into draw_any/render_func would hash/compare it and hang."""
1430
+ if not frames:
1431
+ return []
1432
+ # frames is outermost-first; keep innermost-first so the list runs from the
1433
+ # nearest caller outward.
1434
+ return [(entry[0], entry[1]) for entry in reversed(frames)
1435
+ if not _is_dispatch_frame(entry[0], entry[2])]
1436
+
1437
+
1438
+ def caller_site(frames):
1439
+ """The nearest real caller (filename, lineno) — the first entry of
1440
+ caller_sites, or None. The lens root (caller_arg) reads this for a single
1441
+ cheap-to-hash tuple; see caller_sites for the filtering rationale."""
1442
+ sites = caller_sites(frames)
1443
+ return sites[0] if sites else None
1444
+
1445
+
1446
+ def call_stack_frames(frames):
1447
+ """The WHOLE call stack as lightweight (filename, lineno, func_name) tuples,
1448
+ innermost-first and UNFILTERED. draw_context_menu renders the full stack and
1449
+ decides PER-FRAME (via _is_dispatch_frame) whether to show a plain label
1450
+ (machinery / ignored shell) or an editable code_file_io (a real call site) —
1451
+ so the filtering lives in the render, not here.
1452
+
1453
+ Carries func_name (for the filter decision and the view's name) on top of what
1454
+ caller_sites returns, but still drops the frame's f_locals / source-line, so
1455
+ it stays cheap to hold and hash (see caller_sites)."""
1456
+ if not frames:
1457
+ return []
1458
+ return [(entry[0], entry[1], entry[2]) for entry in reversed(frames)]
1459
+
1460
+
1461
+ def caller_func_name(call_stack):
1462
+ """The func_name of the nearest real caller in a cached _call_stack (the
1463
+ innermost non-dispatch frame) — the function whose body holds the draw_x(...)
1464
+ call, matching caller_site's (filename, lineno). None if no real caller.
1465
+
1466
+ Operates on the already-cached _call_stack tuples (filename, lineno,
1467
+ func_name), so it never touches the live stack."""
1468
+ for filename, lineno, func_name in call_stack:
1469
+ if not _is_dispatch_frame(filename, func_name):
1470
+ return func_name
1471
+ return None
1472
+
1473
+
1474
+ def caller_chain(call_stack):
1475
+ """Every real caller in a cached _call_stack, innermost-first, dispatch
1476
+ machinery and Toggles.ignore_call_from shells filtered out — one
1477
+ (filename, lineno, func_name) per user draw_x(...) call up the stack
1478
+ (caller, caller's caller, ...). The N-step generalization of caller_site /
1479
+ caller_func_name: those return the head of this list.
1480
+
1481
+ Operates on the already-cached _call_stack tuples, so it never re-walks the
1482
+ live stack — a drag re-renders with parents skipped, which would shift every
1483
+ site (see the capture note in core_render)."""
1484
+ return [(filename, lineno, func_name)
1485
+ for filename, lineno, func_name in (call_stack or ())
1486
+ if not _is_dispatch_frame(filename, func_name)]
1487
+
1488
+
1489
+ def _first_call(module):
1490
+ """The outermost cst.Call in a parsed statement (don't descend into nested
1491
+ calls), or None."""
1492
+ found = {}
1493
+
1494
+ class _V(cst.CSTVisitor):
1495
+ def visit_Call(self, node):
1496
+ if 'c' not in found:
1497
+ found['c'] = node
1498
+ return False # outermost only
1499
+
1500
+ module.visit(_V())
1501
+ return found.get('c')
1502
+
1503
+
1504
+ def _modules_for_file(target_path):
1505
+ """EVERY live module whose __file__ resolves to target_path, most-populated
1506
+ first. One file can sit in sys.modules under two names (the src./non-src
1507
+ dual identity), and one of the twins can be a barely-populated stub that
1508
+ never executed its body — sorting by vars() size puts the real, executed
1509
+ module ahead of the stub so callers that take the first match resolve
1510
+ against actual functions.
1511
+
1512
+ Filters on basename before the (syscall-heavy) Path.resolve() so we don't
1513
+ stat every module in sys.modules — that loop was a measurable chunk of the
1514
+ tint-tab open cost."""
1515
+ import sys
1516
+ target_name = target_path.name
1517
+ found = []
1518
+ for m in list(sys.modules.values()):
1519
+ f = getattr(m, "__file__", None)
1520
+ if not f:
1521
+ continue
1522
+ if f.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] != target_name:
1523
+ continue
1524
+ try:
1525
+ if Path(f).resolve() == target_path:
1526
+ found.append(m)
1527
+ except (OSError, ValueError):
1528
+ continue
1529
+ found.sort(key=lambda m: len(vars(m)), reverse=True)
1530
+ return found
1531
+
1532
+
1533
+ def _module_for_file(target_path):
1534
+ """The live module object whose __file__ resolves to target_path, or None —
1535
+ the most-populated twin when the file is imported under several names."""
1536
+ mods = _modules_for_file(target_path)
1537
+ return mods[0] if mods else None
1538
+
1539
+
1540
+ # (filename, lineno, mtime) -> resolved function object (or None). Keyed on mtime
1541
+ # so a hotswap/edit of the file invalidates the entry; the sys.modules walk +
1542
+ # co_firstlineno scan is otherwise repeated every time the caller row is shown.
1543
+ _ENCLOSING_FN_CACHE = {}
1544
+
1545
+ # str(path) -> resolved Path. Session-stable; resolve() is ~30 syscall/GIL
1546
+ # round-trips per _enclosing_function(), per frame under the live-view
1547
+ # overlay (the other half of the 2026-07-31 render-thread realpath samples).
1548
+ _RESOLVED_PATH_CACHE = {}
1549
+
1550
+
1551
+ def _resolved(path_str):
1552
+ got = _RESOLVED_PATH_CACHE.get(path_str)
1553
+ if got is None:
1554
+ if len(_RESOLVED_PATH_CACHE) > 4096:
1555
+ _RESOLVED_PATH_CACHE.clear()
1556
+ got = _RESOLVED_PATH_CACHE[path_str] = Path(path_str).resolve()
1557
+ return got
1558
+
1559
+
1560
+ # str(path) -> (mtime, monotonic checked). The stat below runs per visible
1561
+ # FunctionDef per frame via the live-view overlay's _scope_function (~12% of
1562
+ # draw_text in profiling); a short TTL keeps the mtime-keyed invalidation in
1563
+ # _ENCLOSING_FN_CACHE while collapsing the per-frame stat storm. An edit is
1564
+ # picked up within the TTL, same effect as a debounced reparse.
1565
+ _MTIME_TTL_CACHE = {}
1566
+ _MTIME_TTL_S = 0.25
1567
+
1568
+
1569
+ def _stat_mtime_ttl(target):
1570
+ now = time.monotonic()
1571
+ key = str(target)
1572
+ got = _MTIME_TTL_CACHE.get(key)
1573
+ if got is not None and now - got[1] < _MTIME_TTL_S:
1574
+ return got[0]
1575
+ try:
1576
+ mtime = target.stat().st_mtime
1577
+ except OSError:
1578
+ mtime = None
1579
+ if len(_MTIME_TTL_CACHE) > 4096:
1580
+ _MTIME_TTL_CACHE.clear()
1581
+ _MTIME_TTL_CACHE[key] = (mtime, now)
1582
+ return mtime
1583
+
1584
+
1585
+ def _enclosing_function(filename, lineno):
1586
+ """The live function object whose `def` encloses (filename, lineno) — the
1587
+ nearest def at or above the line, walking module + class scopes. Lets the
1588
+ caller lens hotswap that function after a literal in its body is edited.
1589
+ Returns None for closures/nested funcs not reachable from module vars."""
1590
+ try:
1591
+ target = _resolved(str(filename))
1592
+ except (OSError, ValueError):
1593
+ return None
1594
+ mtime = _stat_mtime_ttl(target)
1595
+ cache_key = (str(target), lineno, mtime)
1596
+ if cache_key in _ENCLOSING_FN_CACHE:
1597
+ return _ENCLOSING_FN_CACHE[cache_key]
1598
+ # Walk EVERY module twin for the file (src./non-src dual identity): the
1599
+ # first might be an unexecuted stub whose vars hold no functions.
1600
+ modules = _modules_for_file(target)
1601
+ if not modules:
1602
+ _ENCLOSING_FN_CACHE[cache_key] = None
1603
+ return None
1604
+ best = {"fn": None, "line": -1}
1605
+
1606
+ def consider(fn):
1607
+ inner = inspect.unwrap(fn)
1608
+ code = getattr(inner, "__code__", None)
1609
+ if code is None:
1610
+ return
1611
+ try:
1612
+ same = _resolved(code.co_filename) == target
1613
+ except (OSError, ValueError):
1614
+ same = code.co_filename == str(target)
1615
+ if same and code.co_firstlineno <= lineno and (
1616
+ code.co_firstlineno > best["line"]
1617
+ # Tie (same def line): the fn-run path's parked exec twin
1618
+ # (`_fnrun_live_<name>`, __fnrun_exec__) that the object runs
1619
+ # publish to and prune on - it must win over the module's
1620
+ # real function, which comes earlier in the module dict, or
1621
+ # the lens reads one store while runs fill it.
1622
+ or (code.co_firstlineno == best["line"]
1623
+ and getattr(inner, "__fnrun_exec__", False)
1624
+ and not getattr(best["fn"], "__fnrun_exec__", False))):
1625
+ best["line"] = code.co_firstlineno
1626
+ best["fn"] = inner
1627
+
1628
+ seen = set()
1629
+
1630
+ def walk(scope):
1631
+ # Descend only into classes DEFINED in the file's modules: an imported
1632
+ # class can be self-referential (ctypes.c_ubyte.__ctype_be__ → c_ubyte -
1633
+ # `from OpenGL.GL import *` puts it in filter.py's namespace) and recursed
1634
+ # to the stack limit (09-04). `seen` is the backstop for cycles between
1635
+ # own classes; foreign classes hold no def from this file anyway.
1636
+ if id(scope) in seen:
1637
+ return
1638
+ seen.add(id(scope))
1639
+ for val in list(vars(scope).values()):
1640
+ if isinstance(val, types.FunctionType):
1641
+ consider(val)
1642
+ elif isinstance(val, (staticmethod, classmethod)):
1643
+ f = getattr(val, "__func__", None)
1644
+ if isinstance(f, types.FunctionType):
1645
+ consider(f)
1646
+ elif isinstance(val, type) and getattr(val, "__module__", None) in module_names:
1647
+ walk(val)
1648
+
1649
+ module_names = {getattr(m, "__name__", None) for m in modules}
1650
+
1651
+ for module in modules:
1652
+ walk(module)
1653
+ _ENCLOSING_FN_CACHE[cache_key] = best["fn"]
1654
+ return best["fn"]
1655
+
1656
+
1657
+ def _resolve_call_address(input_value):
1658
+ """(filename, lineno) -> Address of the enclosing call STATEMENT, with the
1659
+ enclosing function attached as .source.
1660
+
1661
+ Plain function (no imgui, no draw_state) so it can run on a Background thread.
1662
+ Uses the stdlib `ast` module (C-accelerated) to find the outermost Call
1663
+ covering the line, so multi-line calls round-trip as one statement.
1664
+
1665
+ NOTE: do NOT use libcst's MetadataWrapper+PositionProvider here. That pass is
1666
+ pure-Python and O(whole file); on a large caller file (new_core_view.py) it
1667
+ took ~1.1s AND, being CPU-bound, held the GIL — starving the render thread for
1668
+ the duration (an 800ms+ frame stall). ast.parse + ast.walk does the same span
1669
+ lookup in single-digit ms because the parse is in C. The small per-statement
1670
+ span is re-parsed with libcst downstream (address_to_call_parse), where the
1671
+ cost is bounded by the statement, not the file."""
1672
+ import ast
1673
+ filename, lineno = input_value
1674
+ path = Path(filename)
1675
+ address = Address(path, lineno - 1, lineno) # line default
1676
+ try:
1677
+ src_text = path.read_text(encoding='utf-8')
1678
+ tree = ast.parse(src_text, filename=str(path))
1679
+ best_key = None
1680
+ best_node = None
1681
+ for node in ast.walk(tree):
1682
+ if not isinstance(node, ast.Call):
1683
+ continue
1684
+ start = node.lineno
1685
+ end = getattr(node, 'end_lineno', None) or start
1686
+ if start <= lineno <= end:
1687
+ key = (start, -end) # outermost wins
1688
+ if best_key is None or key < best_key:
1689
+ best_key = key
1690
+ best_node = node
1691
+ if best_node is not None:
1692
+ s = best_node.lineno
1693
+ e = best_node.end_lineno or s
1694
+ address = Address(path, s - 1, e)
1695
+ # Column span (UTF-8 byte offsets, per ast) of the call WITHIN its
1696
+ # line span. Lets the save / address_to_call_parse extract just the
1697
+ # call expression even when it's embedded in a larger statement
1698
+ # (e.g. `if ... or button(...):`) and splice the edit back without
1699
+ # disturbing the surrounding prefix/suffix.
1700
+ address._call_cols = (best_node.col_offset, best_node.end_col_offset)
1701
+ # Capture the surrounding prefix/suffix here, while the file is known-
1702
+ # valid (ast just parsed it), so CallerCodec.save has them BEFORE any
1703
+ # save and independent of when load runs. They're intra-line fragments
1704
+ # (no newline), so the '\n' split is newline-agnostic.
1705
+ try:
1706
+ span_lines = _split_lines(src_text)[s - 1:e]
1707
+ pre, _call, suf = _split_span_at_call(
1708
+ span_lines, best_node.col_offset, best_node.end_col_offset, '\n')
1709
+ address._call_prefix = pre
1710
+ address._call_suffix = suf
1711
+ except Exception:
1712
+ pass
1713
+ except Exception as ex:
1714
+ print(f"_resolve_call_address: could not resolve call span in {filename}:{lineno}: {ex}")
1715
+
1716
+ # Enclosing function = recompile hint (None → save-only).
1717
+ address.source = _enclosing_function(filename, lineno)
1718
+ return address
1719
+
1720
+
1721
+ @render_func()
1722
+ def caller_to_address(input_value, draw_state, changed=False):
1723
+ """(filename, lineno) -> Address spanning the call STATEMENT at the call site.
1724
+
1725
+ The actual resolution (full-file libcst parse + PositionProvider + enclosing
1726
+ function lookup) runs on a Background thread via _resolve_call_address — never
1727
+ on the UI thread — and is cached by (filename, lineno, mtime). Returns
1728
+ (changed, None) while the background resolve is pending; the caller row fills
1729
+ in once it lands. Input is the lightweight site from caller_site() — never the
1730
+ raw frames (which carry f_locals)."""
1731
+ if not input_value or len(input_value) != 2:
1732
+ return changed, None
1733
+ filename, lineno = input_value
1734
+ path = Path(filename)
1735
+ # Register the watch on THIS draw_state so _do_save's set_hashed_content
1736
+ # (called with address._watcher_ds = this draw_state) actually opens the
1737
+ # self-write suppress window; otherwise our own write bounces back as an
1738
+ # external change. Idempotent: register_draw_state returns early if already
1739
+ # registered.
1740
+ FileWatch.register_draw_state(draw_state, path)
1741
+ try:
1742
+ mtime = path.stat().st_mtime
1743
+ except OSError:
1744
+ mtime = None
1745
+ cached = getattr(draw_state, '_caller_addr_cache', None)
1746
+ if cached is not None and cached[0] == (filename, lineno) and cached[1] == mtime:
1747
+ return changed, cached[2]
1748
+
1749
+ parent_tile = (draw_state._parent._tile_id
1750
+ if draw_state._parent is not None else draw_state._tile_id)
1751
+ result = Background.run(
1752
+ _resolve_call_address,
1753
+ user_id=str(draw_state.unique) + "|caller_addr",
1754
+ func_kwargs={"input_value": (filename, lineno)},
1755
+ invalidate_id=parent_tile,
1756
+ on_frame=Melty.frame_count,
1757
+ )
1758
+ if isinstance(result, tuple) and len(result) == 2 and isinstance(result[1], int):
1759
+ result = result[0]
1760
+ if not isinstance(result, Address):
1761
+ return changed, None # still resolving on the background thread
1762
+
1763
+ result._watcher_ds = draw_state
1764
+ draw_state._caller_addr_cache = ((filename, lineno), mtime, result)
1765
+ return changed, result
1766
+
1767
+
1768
+ def _split_span_at_call(span_lines, sc, ec, newline):
1769
+ """Split a call's line span into (prefix, call_text, suffix) at the call's
1770
+ column offsets. `sc`/`ec` are UTF-8 byte offsets (ast convention) into the
1771
+ first/last line, so slice in bytes and decode — correct even when the line
1772
+ has multi-byte chars before/inside the call (e.g. font-icon literals)."""
1773
+ first = span_lines[0].encode("utf-8")
1774
+ last = span_lines[-1].encode("utf-8")
1775
+ prefix = first[:sc].decode("utf-8")
1776
+ suffix = last[ec:].decode("utf-8")
1777
+ if len(span_lines) == 1:
1778
+ call_text = first[sc:ec].decode("utf-8")
1779
+ else:
1780
+ middle = span_lines[1:-1]
1781
+ call_text = newline.join(
1782
+ [first[sc:].decode("utf-8")] + middle + [last[:ec].decode("utf-8")])
1783
+ return prefix, call_text, suffix
1784
+
1785
+
1786
+ @render_func(use_cache=True)
1787
+ def address_to_call_parse(input_value, draw_state=None, changed=False, load=False):
1788
+ """Address(call statement) -> dict of the call's kwargs via cst_call_to_dict.
1789
+
1790
+ Carries the enclosing span module + address on the dict (dunder keys, ignored
1791
+ by focus and by dict_to_cst_call's edit scan) so the save node can write back.
1792
+ Re-parses only when the file's mtime changes; otherwise returns the cached
1793
+ dict so focus renders every frame."""
1794
+ if not isinstance(input_value, Address):
1795
+ return changed, None
1796
+ address = input_value
1797
+ try:
1798
+ mtime = address.path.stat().st_mtime
1799
+ except OSError:
1800
+ mtime = None
1801
+ cached = getattr(draw_state, '_call_dict_cache', None)
1802
+ if cached is not None and cached[0] == (address.start, address.end) and cached[1] == mtime:
1803
+ return False, cached[2]
1804
+ try:
1805
+ # Extract JUST the call expression from its line span using the column
1806
+ # offsets resolved by _resolve_call_address. parse_module rejects a span
1807
+ # that's indented or only part of a statement (e.g. `if c or button(...):`
1808
+ # gets "expected INDENT"); slicing out the bare call sidesteps both. The
1809
+ # prefix/suffix around the call on its first/last line are kept so the
1810
+ # save node can splice the edit back without disturbing them.
1811
+ data = address.path.read_bytes()
1812
+ newline = _detect_newline(data)
1813
+ try:
1814
+ text = data.decode("utf-8")
1815
+ except UnicodeDecodeError:
1816
+ text = data.decode("latin-1")
1817
+ span_lines = _split_lines(text)[address.start:address.end]
1818
+ cols = getattr(address, "_call_cols", None)
1819
+ if cols is not None and span_lines:
1820
+ call_prefix, call_text, call_suffix = _split_span_at_call(
1821
+ span_lines, cols[0], cols[1], newline)
1822
+ else:
1823
+ # No column info (ast found no embedded call, line fallback). Treat
1824
+ # the dedented span as the call; nothing to slice around it.
1825
+ import textwrap
1826
+ call_text = textwrap.dedent(newline.join(span_lines))
1827
+ call_prefix = call_suffix = ""
1828
+ span_module = cst.parse_module(call_text)
1829
+ except Exception as ex:
1830
+ print(f"address_to_call_parse: parse failed for {address.path}: {ex}")
1831
+ return False, cached[2] if cached else None
1832
+ call_node = _first_call(span_module)
1833
+ if call_node is None:
1834
+ return False, None
1835
+ converter = Melty._converters.get((cst.Call, dict))
1836
+ d = converter(call_node)
1837
+ d["__call_module__"] = span_module
1838
+ d["__address__"] = address
1839
+ d["__call_prefix__"] = call_prefix
1840
+ d["__call_suffix__"] = call_suffix
1841
+
1842
+ # Human label for the picker/buttons: called-name @ file:line (enclosing fn).
1843
+ func_node = call_node.func
1844
+ if isinstance(func_node, cst.Name):
1845
+ call_name = func_node.value
1846
+ elif isinstance(func_node, cst.Attribute):
1847
+ call_name = func_node.attr.value
1848
+ else:
1849
+ call_name = "call"
1850
+ fn = getattr(address, "source", None)
1851
+ fn_name = getattr(fn, "__name__", None)
1852
+ line_no = (address.start or 0) + 1
1853
+ suffix = f" ({fn_name})" if fn_name else ""
1854
+ d["__label__"] = f"{call_name} {address.path.name}:{line_no}{suffix}"
1855
+
1856
+ draw_state._call_dict_cache = ((address.start, address.end), mtime, d)
1857
+ return True, d
1858
+
1859
+
1860
+ def _build_call_code(d):
1861
+ """Edited call-kwargs dict -> the full line-span source, with the rebuilt call
1862
+ spliced back between its original prefix/suffix.
1863
+
1864
+ Plain + synchronous: rebuilding one call statement (dict_to_cst_call +
1865
+ deep_replace of a tiny span module) is microseconds, unlike the class chain's
1866
+ whole-module reconstruction. It is NOT a background node — that was the bug:
1867
+ as a background node it returned Pending on the edit frame, so the save branch
1868
+ was skipped and the edit lost; and Background.run deep-hashed the CST-laden
1869
+ dict on the UI thread every frame.
1870
+
1871
+ The call was parsed in isolation (just the expression), so new_module.code is
1872
+ the bare call. We re-attach the prefix (indentation + any leading `if … or `)
1873
+ and suffix (`[0]:`, etc.) captured at parse time, so an embedded call splices
1874
+ back into its statement untouched. Continuation lines keep their original
1875
+ absolute indentation (preserved through the round-trip)."""
1876
+ span_module = d.get("__call_module__")
1877
+ old_call = d.get("__cst__")
1878
+ converter = Melty._converters.get((dict, cst.Call))
1879
+ new_call = converter(d)
1880
+ new_module = span_module.deep_replace(old_call, new_call)
1881
+ code = new_module.code.rstrip("\r\n")
1882
+ return d.get("__call_prefix__", "") + code + d.get("__call_suffix__", "")
1883
+
1884
+
1885
+ @render_func(background=True)
1886
+ def recompile_caller_fn(input_value, changed=False):
1887
+ """Hotswap the enclosing function from its full, post-save source on disk.
1888
+
1889
+ The save side writes the edited literal into the file; this reads the whole
1890
+ enclosing `def` back (fresh — linecache evicted) and reuses the function
1891
+ hotswap (_recompile) so the edit goes live. Recompiling just the statement
1892
+ span wouldn't redefine anything, which is why this loads the full function."""
1893
+ address = input_value
1894
+ fn = getattr(address, "source", None)
1895
+ if not isinstance(fn, types.FunctionType):
1896
+ return False, None
1897
+ unwrapped = inspect.unwrap(fn)
1898
+ _evict_linecache(str(address.path))
1899
+ try:
1900
+ src_lines, _start = inspect.getsourcelines(unwrapped)
1901
+ except (OSError, TypeError, tokenize.TokenError, SyntaxError) as e:
1902
+ print(f"recompile_caller_fn: could not read source for {unwrapped.__name__}: {e}")
1903
+ return False, None
1904
+ _recompile(unwrapped, "".join(src_lines), str(address.path))
1905
+ return True, fn
1906
+
1907
+
1908
+ @render_func(use_cache=True, selectable=False)
1909
+ def call_dict_to_save(input_value, draw_state=None, unique=None, changed=False,
1910
+ recompile=False, save=False, s_key_pressed=None):
1911
+ """Edited call-kwargs dict -> write the call statement back to source.
1912
+
1913
+ Disk writes go through run_button(_do_save) (background), like
1914
+ general_parse_to_address — never a synchronous/inline write. The statement
1915
+ code is assembled synchronously (it's a single tiny statement, unlike the
1916
+ class chain's whole-module reconstruction, so it doesn't need a background
1917
+ node) and only on save. Saving fires once per edit (changed) or on Ctrl+S; the
1918
+ file mtime bump then makes address_to_call_parse re-parse a fresh dict.
1919
+
1920
+ The recompile button hotswaps the enclosing function (address.source, set by
1921
+ caller_to_address) from its full post-save source — so it only appears when
1922
+ that function resolved; otherwise this is save-only (shows on next load)."""
1923
+ d = input_value
1924
+ if not isinstance(d, dict):
1925
+ return False, d
1926
+ address = d.get("__address__")
1927
+ if not isinstance(address, Address):
1928
+ imgui.text_colored("Saving unavailable...\nno call address", 1.0, 0.0, 0.0)
1929
+ return False, None
1930
+ source = address.source
1931
+
1932
+ # Recompile button (async). It reads the enclosing function's FULL post-save
1933
+ # source itself, so it does not need the statement code - show it whenever a
1934
+ # source resolved, independent of edits.
1935
+ if source is not None:
1936
+ run_button(recompile_caller_fn, clicked=recompile, name=f"recompile_caller{unique}",
1937
+ with_kwargs={"input_value": address})
1938
+ imgui.dummy(1, 1)
1939
+
1940
+ # Save - ALWAYS async, through run_button(_do_save) (_do_save is
1941
+ # @render_func(background=True)), exactly like general_parse_to_address. Never a
1942
+ # bare _do_save(...) - that wrote to disk synchronously. Fires on an edit
1943
+ # (save) or Ctrl+S. The statement code is assembled synchronously (pure
1944
+ # CST→string, no disk I/O) and only when saving: doing it every frame
1945
+ # deep-hashed the CST on the UI thread, and as a background node it returned
1946
+ # Pending on the edit frame and dropped the save.
1947
+ save_hotkey = bool(s_key_pressed and s_key_pressed.ctrl)
1948
+ if changed or save_hotkey:
1949
+ run_button(_do_save, clicked=(save or save_hotkey), name=f"do_save{unique}",
1950
+ with_kwargs={"input_value": address, "code_str": _build_call_code(d)})
1951
+ if Melty.cache is not None and draw_state.parent_window is not None:
1952
+ Melty.cache.invalidate_up(draw_state.parent_window._tile_id, max_depth=10)
1953
+ return True, address
1954
+ return False, address
1955
+
1956
+
1957
+ @render_func(use_cache=True)
1958
+ def address_to_class(input_value, changed=False, draw_state=None):
1959
+ pass
1960
+
1961
+
1962
+ @render_func(use_cache=True)
1963
+ def address_to_function(input_value, changed=False, draw_state=None):
1964
+ pass
1965
+
1966
+
1967
+ @render_func(use_cache=True)
1968
+ def address_to_module(input_value, changed=False, draw_state=None):
1969
+ pass
1970
+
1971
+
1972
+ @render_func(use_cache=True)
1973
+ def cst_to_address(input_value, changed=False, draw_state=None):
1974
+ pass
1975
+
1976
+
1977
+ @render_func(use_cache=True)
1978
+ def general_parse_to_str(input_value, changed=False, draw_state=None):
1979
+ """Convert cst.Module → source string. Pure converter, no UI."""
1980
+ if isinstance(input_value, GeneralParse):
1981
+ return changed, input_value.source
1982
+ else:
1983
+ return False, None
1984
+
1985
+
1986
+ @render_func(background=True)
1987
+ def parse_source_to_general(input_value):
1988
+ """Parse an edited source string back into a GeneralParse, on a background
1989
+ thread. cst.parse_module + cst_module_to_dict are O(buffer); running them
1990
+ inline on str_to_general_parse blocked every keystroke. Mirrors CODE_UI's
1991
+ load_cst_module. A syntax error mid-edit raises here and surfaces as a
1992
+ PendingState.ERROR (Background.run catches it)."""
1993
+ if Toggles.TextEditor.melty_syntax:
1994
+ return True, cst_module_to_dict(str(input_value)) # core_syntax (str input)
1995
+ cst_module = cst.parse_module(str(input_value))
1996
+ return True, cst_module_to_dict(cst_module)
1997
+
1998
+
1999
+ # Wait this long after the last edit before parsing. A full-buffer parse is
2000
+ # CPU-bound Python (cst.parse_module + cst_module_to_dict), so doing it per
2001
+ # keystroke blocks the next frame whether it runs inline or on a GIL-bound
2002
+ # pool thread. The parse only feeds save/round-trip, not the displayed text, so
2003
+ # deferring it until typing settles keeps keystrokes smooth.
2004
+ _PARSE_DEBOUNCE_S = 0.1
2005
+
2006
+
2007
+ def _blank_line_variant(last_good, cur):
2008
+ """`cur` with the single line that differs from `last_good` blanked, or
2009
+ None when the texts aren't a one-line-apart pair (different line counts,
2010
+ zero or 2+ differing lines). The one differing line during live typing is
2011
+ the line being edited — blanking it usually restores a parseable buffer
2012
+ while keeping every OTHER line's content and, crucially, line NUMBERS
2013
+ intact (so usage sites / tints resolve unshifted)."""
2014
+ if not last_good or not cur:
2015
+ return None
2016
+ a = last_good.split("\n")
2017
+ b = cur.split("\n")
2018
+ if len(a) != len(b):
2019
+ return None
2020
+ diff = -1
2021
+ for i, (la, lb) in enumerate(zip(a, b)):
2022
+ if la != lb:
2023
+ if diff != -1:
2024
+ return None
2025
+ diff = i
2026
+ if diff == -1:
2027
+ return None
2028
+ b[diff] = ""
2029
+ return "\n".join(b)
2030
+
2031
+
2032
+ def _blank_line_reparse(draw_state, input_str):
2033
+ """Mid-edit syntax-error recovery: when the edit differs from the last
2034
+ successfully parsed text by exactly one line, reparse with that line
2035
+ blanked. Returns the repaired parse when it lands, else None (no one-line
2036
+ variant, background job still running, or the variant is broken too —
2037
+ e.g. the blanked line was a block header). The variant is derived once
2038
+ per input string; the parse itself rides the normal background node and
2039
+ its completion wakes the view."""
2040
+ if getattr(draw_state, '_blank_variant_key', None) != input_str:
2041
+ draw_state._blank_variant_key = input_str
2042
+ draw_state._blank_variant = _blank_line_variant(
2043
+ getattr(draw_state, '_last_propagated_str', None), input_str)
2044
+ patched = draw_state._blank_variant
2045
+ if patched is None:
2046
+ return None
2047
+ _c, parse = parse_source_to_general(input_value=patched)
2048
+ if isinstance(parse, Pending) or parse is None:
2049
+ return None
2050
+ return parse
2051
+
2052
+
2053
+ @render_func(use_cache=True)
2054
+ def str_to_general_parse(input_value, reference=None, changed=False, draw_state=None):
2055
+ input_str = str(input_value)
2056
+ has_ref = isinstance(reference, GeneralParse)
2057
+
2058
+ # Debounce the parse: while the text is still changing (or hasn't been
2059
+ # stable for _PARSE_DEBOUNCE_S), keep showing the prior parse and don't
2060
+ # touch the UI. request_render keeps frames coming until the timer elapses.
2061
+ # Skipped during app startup (global frame_count) AND while the view itself
2062
+ # is loading (its own draw_state.frame_count) so a freshly opened text view
2063
+ # parses immediately instead of waiting out the debounce window.
2064
+ if Melty.frame_count >= 3 and draw_state.frame_count >= 3:
2065
+ if input_str != getattr(draw_state, '_parse_pending_str', None):
2066
+ draw_state._parse_pending_str = input_str
2067
+ draw_state._parse_pending_at = time.monotonic()
2068
+ request_render()
2069
+ return False, reference if has_ref else None
2070
+ if (input_str != getattr(draw_state, '_last_propagated_str', None)
2071
+ and time.monotonic() - getattr(draw_state, '_parse_pending_at', 0.0) < _PARSE_DEBOUNCE_S):
2072
+ request_render()
2073
+ return False, reference if has_ref else None
2074
+
2075
+ # Parse the edited text back to a parse tree off the main thread.
2076
+ # `reference` is the chain's cached prior parse for this position; we keep
2077
+ # it flowing while the background parse is pending or the edit is unstable,
2078
+ # so downstream save/recompile always get a valid (last-good) parse.
2079
+ _c, general_parse = parse_source_to_general(input_value=input_str)
2080
+
2081
+ if isinstance(general_parse, Pending) or general_parse is None:
2082
+ if isinstance(general_parse, Pending) and general_parse.state == PendingState.ERROR:
2083
+ # Incomplete/invalid syntax mid-edit: before falling back to the
2084
+ # stale reference wholesale, try a one-line repair - if only the
2085
+ # line being typed changed since the last good parse, a variant
2086
+ # with that line blanked usually parses, so usage data / tints on
2087
+ # every other line survive the broken keystrokes. changed stays
2088
+ # False: the repaired parse is display/context only, it must
2089
+ # not propagate into a save or recompile.
2090
+ repaired = _blank_line_reparse(draw_state, input_str)
2091
+ imgui.text_colored("Error parsing code", 1.0, 0.0, 0.0)
2092
+ if repaired is not None:
2093
+ repaired.source = input_str # keep the REAL edited text live
2094
+ if has_ref:
2095
+ repaired.address = reference.address
2096
+ return False, repaired
2097
+ # Keep the edited text live on the stale reference.
2098
+ if has_ref:
2099
+ reference.source = input_str
2100
+ # Background still running (or nothing changed): keep the previous parse.
2101
+ return False, reference if has_ref else None
2102
+
2103
+ if not has_ref:
2104
+ draw_state._last_propagated_str = input_str # blank-line-repair baseline
2105
+ return False, general_parse
2106
+ general_parse.address = reference.address
2107
+ # Propagate changed=True once, on the frame a new parse first lands, so
2108
+ # downstream save/recompile can notice it without re-firing every frame.
2109
+ fresh = getattr(draw_state, '_last_propagated_str', None) != input_str
2110
+ draw_state._last_propagated_str = input_str
2111
+ return fresh, general_parse