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,3017 @@
1
+ """
2
+ Source/file round-trip for editable views — load, edit, save, recompile, all in
3
+ one @render_func whose state lives on its own draw_state.
4
+
5
+ The whole flow is `code_file_io`. It reads top to bottom; nothing crosses a node
6
+ boundary, so there is no chain executor and no shared cache tree to thread:
7
+
8
+ resolve address → load → edit (nested view) → save / recompile
9
+
10
+ Three pieces carry the design:
11
+
12
+ CODECS (`new_codecs.py`) own how a given input becomes editable text and how an
13
+ edit goes back. A codec is picked by the input's TYPE (`type_to_codec`, e.g.
14
+ class / function / module → source span) or, for paths, by EXTENSION
15
+ (`extension_to_codec`, e.g. .png → texture). `code_file_io` only ever calls
16
+ `codec.resolve_address`, `codec.load`, `codec.save` — it knows nothing about
17
+ spans, files, or formats. Adding a filetype is adding a codec, not touching this
18
+ file.
19
+
20
+ VIEWS are codec-agnostic and reusable. `code_file_io` hands the loaded value to
21
+ an injected `view_func` (default `draw_text`). `draw_modes` is the interesting
22
+ one: it shows a tab per repr (text | structured), running a `chain_in` (e.g.
23
+ str → cst → dict for `draw_collection`) and `chain_out` back on edit. BOTH
24
+ chains run on a background worker (`_run_chain_in` / `_run_chain_out` via
25
+ `run_in_background`) — they are O(buffer) cst rebuilds and would otherwise stall
26
+ the render loop on every keystroke / drag. `_run_convert` is the inline executor
27
+ the worker calls (bare functions, no threads, no imgui). A parse failure is
28
+ treated as a VALUE — a cst error over half-typed source is a normal editor
29
+ state, surfaced as UI (and as a red line highlight, see `text_editor`), with
30
+ structured views falling back to the last good parse (`ModesState`).
31
+
32
+ ASYNC + DEBOUNCE. load / save / recompile / chain_in / chain_out each run through
33
+ `run_in_background`,
34
+ a one-shot worker keyed by a distinct `name=` so they never clobber each other.
35
+ Load is effectively cached (re-offered only on disk change); save auto-fires on
36
+ edit but is debounced (`save_debounce_ms`) so a burst of keystrokes collapses
37
+ into one write — a one-shot timer wakes the loop at the deadline instead of
38
+ spinning `request_render`. An explicit Save / Ctrl+S bypasses the debounce.
39
+ Saves fire during drags too (the deferred save just queues the edit in memory —
40
+ PendingSave — so it's cheap; the disk write happens once at flush).
41
+
42
+ ASYNC NEVER LAGS THE UI — the one design rule everything above serves. The live
43
+ buffer (text_cache / a host's held value) is ALWAYS the newest state and is what
44
+ the UI renders; background work only ever consumes SNAPSHOTS of it and may not
45
+ push results back over it ungated. Concretely:
46
+ * The save is a trailing, write-only side channel. While a save is queued or
47
+ in flight, edits keep landing in the buffer, the UI keeps rendering them,
48
+ and every edit refreshes the queued save's snapshot (run_in_background's
49
+ one-slot queue). Nothing about the save's lifecycle — debounce, drag hold,
50
+ in-flight write, completion, even its own mtime bump reading back as a
51
+ stale file — may suppress edits, freeze the snapshot, or substitute an
52
+ older value for what the UI shows. A save-vintage value re-entering the
53
+ display path (e.g. a false "changed on disk" conflict from our own write
54
+ starving save_start, so the queue wrote an old buffer that file-syncing
55
+ views then displayed) is THE classic bug here; see the INVARIANT comment at
56
+ the save site in code_file_io and run_in_background's docstring.
57
+ * Background results that legitimately flow back (a load, a chain_in parse)
58
+ go through ordering gates — frame-precedence / generation tags in
59
+ render_host, the pending-save gate on reloads — so an older result can
60
+ never clobber a newer local edit.
61
+
62
+ Syntax-error feedback belongs to the editor, not this file. chain_in parses the
63
+ buffer on its background thread every time the text changes; the route hands that
64
+ result to draw_text — `code_tree` on success, the parse exception on failure —
65
+ which highlights the offending line. code_file_io does no parsing of its own and
66
+ displays no error, so the highlight clears as soon as a fresh background parse
67
+ succeeds. Recompile (hotswap, no disk write) is a separate concern: it just
68
+ hotswaps the live object and flashes a checkmark.
69
+ """
70
+ from meltygui.core.runtime.extensions import get as get_service
71
+
72
+ import inspect
73
+ import linecache
74
+ import sys
75
+ import textwrap
76
+ import threading
77
+ import time
78
+ import tokenize
79
+ import traceback
80
+ import types
81
+ from collections import defaultdict
82
+ from datetime import datetime
83
+ from enum import Enum
84
+ from pathlib import Path
85
+
86
+ import meltygui_imgui as imgui
87
+ import libcst as cst
88
+
89
+ import meltygui.core.runtime.toggles as toggles
90
+ from meltygui.core.melty import FileWatch
91
+ from meltygui.core.melty import Melty
92
+ from meltygui.core.conversion.dict_conversion import DictConversion
93
+ from meltygui.core.rendering.modes import Modes
94
+ from meltygui.core.diagnostics.notifications import notify
95
+ from meltygui.core.rendering.render_funcs import RenderFuncs
96
+ from meltygui.core.runtime.toggles import Toggles
97
+ from meltygui.core.windowing.glfw_utils import request_render
98
+ from meltygui.core.windowing.glfw_utils import print_stack_trace
99
+ from meltygui.core.windowing.glfw_utils import get_exception_frames
100
+ import meltygui.code.hotswap_guard as hotswap_guard
101
+ from meltygui.code.fileref import Address
102
+ from meltygui.code.fileref import _evict_linecache
103
+ from meltygui.code.chain_converters import record_compile
104
+ from meltygui.code.chain_converters import _enclosing_function
105
+ from meltygui.code.chain_converters import live_apply_edits
106
+ from meltygui.code.chain_converters import _blank_line_variant
107
+ from meltygui.code.chain_converters import chain_parse_cache_get
108
+ from meltygui.code.chain_converters import chain_parse_cache_put
109
+ from meltygui.code.chain_converters import chain_parse_cache_has
110
+ from meltygui.code.code_checks import check_source
111
+ from meltygui.code.code_checks import check_source_incremental
112
+ from meltygui.code.code_checks import collect_import_suggestions
113
+ from meltygui.code.file_converters import _recompile
114
+ from meltygui.code.file_converters import _recompile_class
115
+ from meltygui.code.file_converters import _recompile_module
116
+ from meltygui.code.file_converters import module_for_path
117
+ from meltygui.code.libcst_conversion import cst_module_to_dict
118
+ from meltygui.code.libcst_conversion import dict_to_cst_module
119
+ from meltygui.code.new_codecs import Codec
120
+ from meltygui.code.new_codecs import CallSite
121
+ from meltygui.code.new_codecs import Decorations
122
+ from meltygui.code.new_codecs import SaveConflict
123
+ from meltygui.code.new_codecs import type_to_codec
124
+ from meltygui.code.new_codecs import extension_to_codec
125
+ from meltygui.code.new_codecs import codec_for_path
126
+ from meltygui.core.core_render import render_func
127
+ from meltygui.core.rendering.core_decoration import no_save_exclude
128
+ from meltygui.core.rendering.core_decoration import no_save
129
+ from meltygui.core.rendering.window_decoration import window
130
+ from meltygui.view.header_view import draw_header
131
+ from meltygui.editor.pending_save import PendingSave
132
+ from meltygui.core.cache.invalidation_tracker import Note
133
+ from meltygui.core.rendering.core_decoration import defaults
134
+ from meltygui.core.diagnostics.perf_trace import trace as _ptrace
135
+ from meltygui.core.diagnostics.perf_trace import trace_rl as _ptrace_rl
136
+ from meltygui.core.diagnostics.perf_trace import span as _pspan
137
+ from meltygui.core.diagnostics.perf_trace import once as _ponce
138
+
139
+
140
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
141
+ # ║ Core helpers - load / write / recompile (plain synchronous, no chain magic) ║
142
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
143
+
144
+
145
+ def _focus_inside_ds(ds):
146
+ """True when the current text focus sits inside `ds`'s subtree — i.e. the
147
+ save being queued originates from the editor the user is typing in. Walks
148
+ up from Melty.text_focused_ds via _parent (self-loop root) then
149
+ parent_window hops, same shape as _preferred_source_for's ancestor walk."""
150
+ if ds is None:
151
+ return False
152
+ node, hops = Melty.text_focused_ds, 0
153
+ while node is not None and hops < 32:
154
+ if node is ds:
155
+ return True
156
+ parent = getattr(node, "_parent", None)
157
+ nxt = parent if parent is not None and parent is not node else None
158
+ if nxt is None:
159
+ pw = getattr(node, "parent_window", None)
160
+ nxt = pw if pw is not None and pw is not node else None
161
+ node = nxt
162
+ hops += 1
163
+ return False
164
+
165
+
166
+ def save_file(address, code_str, codec=None, ensure_import=None, parent_ds=None, force=False):
167
+ """Write the edited value back through the resolved codec (span splice for
168
+ code, whole-file for images, etc.). Returns the codec's result — a
169
+ SaveConflict when the codec refused the splice because the on-disk span
170
+ changed under us (force=True, the user's explicit Keep-mine, bypasses)."""
171
+ current_time = datetime.now().strftime("%H:%M:%S")
172
+ file_name = address.path.name if address.path is not None else "unknown"
173
+ notify(f"Saved {file_name} from {parent_ds.name}", tint=(0.5, 1.0, 0.5))
174
+ # wake=False only for the typing editor's own per-keystroke auto-save
175
+ # (focus inside this wrapper's subtree) - waking every visible editor of
176
+ # the file per keystroke is the storm the wake was once disabled for. A
177
+ # programmatic save through this same runner (a side-panel/lens edit
178
+ # flowing out of the same cache host) keeps the wake so the visible
179
+ # editor's draw_text is invalidated promptly.
180
+ _wake = not _focus_inside_ds(parent_ds)
181
+ _ptrace(f"save_file wake={_wake} parent_ds={getattr(parent_ds, 'name', None)}",
182
+ file=file_name)
183
+ PendingSave.queue_save(address=address, codec=codec, data=code_str, ensure_import=ensure_import, force=force,
184
+ wake=_wake)
185
+ return True
186
+ # return codec.save(address=address, data=code_str, ensure_import=ensure_import, force=force)
187
+
188
+
189
+ def load_file(input_value: Address, codec: Codec = None, **kwargs) -> str:
190
+ """Read the value through the resolved codec (span for code, whole file for
191
+ images, etc.)."""
192
+ file_name = input_value.path.name if input_value.path is not None else "unknown"
193
+ notify(f"Loading {file_name}...", tint=(0.5, 1.0, 0.5))
194
+ with _pspan("load_file", file=file_name):
195
+ data = codec.load(input_value)
196
+ PendingSave.mark_load(address=input_value, codec=codec, data=data)
197
+ return data
198
+
199
+
200
+ def recompile_source(source, code_str, file_path, address=None):
201
+ """Hotswap the edited code in place (no disk write) — do_recompile's dispatch.
202
+
203
+ type / function / module: code_str IS the whole object's source, so it
204
+ recompiles directly. A CallSite is different — code_str is a single statement
205
+ inside a function body, which redefines nothing on its own — so we recompile
206
+ the ENCLOSING function instead (see _recompile_caller). Decorations is the same
207
+ shape: code_str is just the `@...` block, which redefines nothing alone, so we
208
+ recompile the WHOLE decorated object (see _recompile_decorations)."""
209
+ result = None
210
+ notify(f"Recompiling {getattr(source, '__name__', str(source))}...", tag="recompile", tint=(0.5, 1.0, 0.5))
211
+ if isinstance(source, Decorations):
212
+ result = _recompile_decorations(source, code_str, file_path, address)
213
+ elif isinstance(source, type):
214
+ result = _recompile_class(source, code_str, str(file_path))
215
+ elif isinstance(source, types.FunctionType):
216
+ result = _recompile(source, code_str, str(file_path))
217
+ elif isinstance(source, types.ModuleType):
218
+ result = _recompile_module(source, code_str, str(file_path))
219
+ elif isinstance(source, CallSite):
220
+ result = _recompile_caller(source, code_str, file_path, address)
221
+ elif isinstance(source, (str, Path)) and str(file_path).endswith(".py"):
222
+ # Whole-file edits resolve through TextFileCodec, whose Address
223
+ # carries the PATH as source - resolve the live module here so the file
224
+ # recompile patches its classes/functions the same way the span
225
+ # recompile does. No live module is a real failure (previously this
226
+ # fell through to result=None and reported success while swapping
227
+ # nothing).
228
+ module = module_for_path(file_path)
229
+ if module is not None:
230
+ result = _recompile_module(module, code_str, str(file_path))
231
+ else:
232
+ result = NameError(f"no live module loaded from {file_path} — "
233
+ f"nothing to hotswap")
234
+ return result
235
+
236
+
237
+ def _recompile_decorations(decorations, deco_str, file_path, address):
238
+ """Hotswap a class/function after its DECORATOR block was edited.
239
+
240
+ The codec hands code_file_io just the `@...` lines (DecorationsCodec edits the
241
+ decorator block, not the def/class), so recompiling `deco_str` alone redefines
242
+ nothing. The decorated OBJECT is the unit that recompiles: we read its current
243
+ source, splice the edited decorator lines over the decorator span, then re-run
244
+ the whole object through the normal class/function recompile — which re-executes
245
+ the decorators (already handled by _recompile / _recompile_class).
246
+
247
+ Splicing the live buffer over the on-disk object source (rather than reading the
248
+ object back from disk) makes the hotswap reflect the edit before the debounced
249
+ save lands — same trick as _recompile_caller."""
250
+ target = decorations.target
251
+ if not isinstance(target, (type, types.FunctionType)):
252
+ return None
253
+ unwrapped = inspect.unwrap(target) if isinstance(target, types.FunctionType) else target
254
+ _evict_linecache(str(file_path))
255
+ try:
256
+ # getsourcelines starts at the first decorator (1-based obj_start) - so its
257
+ # line list is shifted with the address's decorator span at the top.
258
+ obj_lines, obj_start = inspect.getsourcelines(unwrapped)
259
+ except (OSError, TypeError, tokenize.TokenError, SyntaxError) as e:
260
+ print(f"_recompile_decorations: could not read source for "
261
+ f"{getattr(unwrapped, '__name__', unwrapped)}: {e}")
262
+ return None
263
+
264
+ new_source = "".join(obj_lines)
265
+ if address is not None and address.start is not None and address.end is not None:
266
+ rel_start = address.start - (obj_start - 1)
267
+ rel_end = address.end - (obj_start - 1)
268
+ if 0 <= rel_start <= rel_end <= len(obj_lines):
269
+ deco = deco_str
270
+ if deco.endswith("\r\n"):
271
+ deco = deco[:-2]
272
+ elif deco.endswith("\n"):
273
+ deco = deco[:-1]
274
+ deco_lines = [line + "\n" for line in deco.splitlines()] if deco else []
275
+ new_source = "".join(obj_lines[:rel_start] + deco_lines + obj_lines[rel_end:])
276
+
277
+ if isinstance(target, type):
278
+ return _recompile_class(target, new_source, str(file_path))
279
+ return _recompile(unwrapped, new_source, str(file_path))
280
+
281
+
282
+ def _recompile_caller(call_site, stmt_str, file_path, address):
283
+ """Hotswap the function ENCLOSING a call site, with the edited statement
284
+ spliced into its live source.
285
+
286
+ The codec hands code_file_io just the call EXPRESSION (CallerCodec edits the
287
+ bare `foo(...)`, not the statement around it), so recompiling `stmt_str` alone
288
+ would redefine nothing — and splicing it raw would drop the statement's
289
+ indentation/prefix (`if `, `x = `) and suffix (`[0]:`), landing the call at
290
+ col 0 → IndentationError. The enclosing function is the unit that recompiles —
291
+ already resolved onto `address.source` by CallerCodec (via _enclosing_function),
292
+ with a fallback re-resolve from the site if that's missing.
293
+
294
+ We read the function's current source and splice the FULL reconstructed
295
+ statement (prefix + edited call + suffix, the same reattachment CallerCodec.save
296
+ does) over its span, then recompile the whole def. Splicing (rather than just
297
+ reading the def back from disk, the old recompile_caller_fn approach) makes the
298
+ hotswap reflect the LIVE buffer even before the debounced disk save lands: the
299
+ unedited lines come from disk, the edited statement from the buffer."""
300
+ fn = getattr(address, "source", None) if address is not None else None
301
+ if not isinstance(fn, types.FunctionType):
302
+ fn = _enclosing_function(call_site.filename, call_site.lineno)
303
+ if not isinstance(fn, types.FunctionType):
304
+ return None
305
+
306
+ unwrapped = inspect.unwrap(fn)
307
+ _evict_linecache(str(file_path))
308
+ try:
309
+ fn_lines, fn_start = inspect.getsourcelines(unwrapped) # fn_start is 1-based
310
+ except (OSError, TypeError, tokenize.TokenError, SyntaxError) as e:
311
+ print(f"_recompile_caller: could not read source for {unwrapped.__name__}: {e}")
312
+ return None
313
+
314
+ # Splice the edited statement over its span in the function. Address spans
315
+ # are file-absolute 0-based (load convention); shift them to the function's
316
+ # own line list. Guard the bounds - a stale/odd span falls back to recompiling
317
+ # the function's unedited disk source rather than blanking it.
318
+ new_source = "".join(fn_lines)
319
+ if address is not None and address.start is not None and address.end is not None:
320
+ rel_start = address.start - (fn_start - 1)
321
+ rel_end = address.end - (fn_start - 1)
322
+ if 0 <= rel_start <= rel_end <= len(fn_lines):
323
+ # Reattach the prefix/suffix CallerCodec stripped (the `if `/=` and
324
+ # `[0]:` around the call), then splice - exactly like CallerCodec.save.
325
+ prefix = getattr(address, "_call_prefix", "")
326
+ suffix = getattr(address, "_call_suffix", "")
327
+ stmt = stmt_str
328
+ if stmt.endswith("\r\n"):
329
+ stmt = stmt[:-2]
330
+ elif stmt.endswith("\n"):
331
+ stmt = stmt[:-1]
332
+ full_stmt = prefix + stmt + suffix
333
+ stmt_lines = [line + "\n" for line in full_stmt.splitlines()]
334
+ new_source = "".join(fn_lines[:rel_start] + stmt_lines + fn_lines[rel_end:])
335
+
336
+ return _recompile(unwrapped, new_source, str(file_path))
337
+
338
+
339
+ class TestClass:
340
+ some_val = -2
341
+ some_other_val = 73
342
+ tint = (0.52, 0.80, 0.688)
343
+ tint = (0.52, 0.80, 0.688)
344
+
345
+ # [tint=(0.7722222, 0.5336913466453552, 0.17589502036571503)]
346
+ def some_func(a=84, b=-153):
347
+ imgui.set_cursor_pos()
348
+
349
+ some_line = 87
350
+ myflot = 5
351
+
352
+ aomw_list = 62
353
+
354
+ list_new = [1, -1, 12]
355
+
356
+ # [tint=(0.31290125846862793, 0.6864094, 0.7611111402511597)]
357
+ class NestedClass:
358
+ so = 31
359
+
360
+ some_nested = NestedClass()
361
+
362
+ new_bool = True
363
+
364
+
365
+ def slow_task(**kwargs):
366
+ import time
367
+ print("Starting slow task...")
368
+ time.sleep(1);
369
+ print("Slow task completed.")
370
+ return {"result": "This is the result of the slow task", "kwargs": kwargs}
371
+ print("Slow task completed.")
372
+ return {"result": "This is the result of the slow task", "kwargs": kwargs}
373
+ print("Slow task completed.")
374
+ return {"result": "This is the result of the slow task", "kwargs": kwargs}
375
+
376
+
377
+ # @window()
378
+ # @render_func(use_cache=True)
379
+ # def editor_window():
380
+ # code_file_io(
381
+ # TestClass,
382
+ # mode=Modes.NEW_CODE
383
+ # )
384
+ # return False, None
385
+ # #
386
+ #
387
+ # @window()
388
+ # @render_func(use_cache=True)
389
+ # def editor_window_2():
390
+ # code_file_io(
391
+ # TestClass,
392
+ # view_func=convert_in_and_out,
393
+ # auto_load_edits=False,
394
+ # auto_load=False,
395
+ # auto_save=False,
396
+ # child_kwargs={
397
+ # # convert_in_and_out runs the chains, draw_with_view_funcs draws the
398
+ # # tabs/columns. string_to_cst_module's output is named "code_tree" -
399
+ # # draw_text uses it to highlight parse errors (a failed parse arrives
400
+ # # as the exception value). cst_module_to_dict's output is "code_dict",
401
+ # # which draw_collection consumes. draw_text gets the raw string as its
402
+ # # input_value (no route entry).
403
+ # "view_func": draw_with_view_funcs,
404
+ # "chain_in": [string_to_cst_module, cst_module_to_dict],
405
+ # "chain_out": [dict_to_cst_module, cst_module_to_string],
406
+ # "route": {
407
+ # string_to_cst_module: "code_tree",
408
+ # cst_module_to_dict: "code_dict",
409
+ # RenderFuncs.draw_collection: "code_dict",
410
+ # },
411
+ # "child_kwargs": {
412
+ # "view_funcs": [RenderFuncs.draw_text, RenderFuncs.draw_collection],
413
+ # },
414
+ # },
415
+ # )
416
+ # return False, None
417
+
418
+
419
+ # @window()
420
+ # @render_func(use_cache=True, disable_scroll=True)
421
+ # def draw_collection_code():
422
+ # # view_func=draw_modes: text | dict tabs over one shared file-IO layer.
423
+ # code_file_io(
424
+ # RenderFuncs.draw_collection,
425
+ # mode=Modes.NEW_CODE
426
+ # )
427
+ # return False, None
428
+
429
+
430
+ # @window()
431
+ # @render_func(use_cache=True)
432
+ # def editor_window_3():
433
+ # code_file_io(slow_task, auto_load_edits=False, auto_load=False)
434
+ # return False, None
435
+ #
436
+ #
437
+ # @window()
438
+ # @render_func(use_cache=True, selectable=False, disable_scroll=True)
439
+ # def test_toggles():
440
+ # code_file_io(Toggles, mode=Modes.NEW_CODE)
441
+ # return False, None
442
+
443
+
444
+ class LoadingState:
445
+ def __init__(self):
446
+ self._loading = False
447
+ self.cached_result = UNSET
448
+ self._run_next = None
449
+ self._pending_change = False
450
+ self.error = None
451
+ self._loading_start_frame = None
452
+ # Wall-clock deadline (time.time()) the queued task must wait until before
453
+ # it launches. None = no debounce. Each fresh `start` pushes it out.
454
+ self._debounce_deadline = None
455
+ # One-shot timer that wakes the render loop once at the deadline, so we
456
+ # don't busy-spin request_render every frame during the quiet window.
457
+ self._debounce_timer = None
458
+ # Perf-trace stamps (arm edge time + task label) for the timeline log
459
+ # below run_in_background - diagnostics only, no behavior.
460
+ self._armed_t = None
461
+ self._armed_label = None
462
+
463
+
464
+ UNSET = object()
465
+ LOADING = object()
466
+
467
+
468
+ @render_func(use_cache=True, selectable=False, temp=True)
469
+ def run_in_background(input_value, loading_state: LoadingState, unique,
470
+ draw_state, child_kwargs, start=False, timeout=20,
471
+ debounce_ms=0, main_thread=False, inline_first=False,
472
+ **kwargs):
473
+ """One-shot background runner: call it every frame; `start=True` is the
474
+ trigger edge that snapshots (input_value, child_kwargs) into the queue.
475
+
476
+ Returns (changed, value):
477
+ (False, LOADING) — BUSY: a run is in flight, OR a run finished but a
478
+ newer one is already queued (see below).
479
+ (True, result) — a run completed AND nothing newer is queued.
480
+ Reported exactly once.
481
+ (False, cached_result) — idle; last completed result (UNSET before any).
482
+
483
+ Queue semantics — latest-only, coalesced:
484
+ * The queue is ONE slot (`_run_next`). Every `start` overwrites it, so the
485
+ eventual run always uses the LATEST snapshot. Callers must therefore keep
486
+ re-triggering `start` while their input keeps changing — anything that
487
+ gates the trigger (e.g. code_file_io's `not conflict`) freezes the queued
488
+ snapshot at the last armed value, and the run will execute OLD data.
489
+ * Completion is reported only when the queue is empty, so consumers only
490
+ ever see the final result of a burst (render_host's materialize gate
491
+ relies on a reported result never being superseded).
492
+ * A completed-but-superseded run is BUSY, not idle: its completion edge is
493
+ deliberately swallowed (coalescing), so the runner must not present an
494
+ idle return — consumers attach side effects to the busy state
495
+ (code_file_io's mark_file_current absorbs the save's own mtime bump on
496
+ LOADING frames; an idle return there once latched a false "changed on
497
+ disk" conflict that starved the auto-save snapshot — the
498
+ old-value-written-during-save bug).
499
+
500
+ Debounce: `debounce_ms` defers the launch until the trigger goes quiet (a
501
+ one-shot timer wakes the loop at the deadline — never per-frame polling).
502
+ It only ever applies to RE-runs: while there's no result yet (first load),
503
+ the launch is immediate, so a debounced caller never trades first-paint
504
+ latency for burst-coalescing."""
505
+ if Melty.frame_count < 4 or main_thread or loading_state.cached_result is UNSET:
506
+ debounce_ms = 0
507
+ if start:
508
+ # Timeline of the arm edge. _armed_t / _armed_label for the run + report
509
+ # traces below, so the log shows arm → run (queue wait) → report (frame
510
+ # hops) as three stamps per task instead of one opaque duration.
511
+ loading_state._armed_t = time.perf_counter()
512
+ loading_state._armed_label = getattr(input_value, '__name__', 'task')
513
+ _ptrace(f"rib: armed {loading_state._armed_label}",
514
+ debounce_ms=debounce_ms, inline_first=inline_first)
515
+ loading_state._run_next = input_value, child_kwargs
516
+ if debounce_ms:
517
+ # Debounce: defer the launch until the input goes quiet. Re-start on
518
+ # every start (a burst of typing keeps pushing it out), and wake the
519
+ # loop ONCE at the deadline via a one-shot timer - never busy-spin
520
+ # request_render per frame, or we peg the whole render thread. The
521
+ # run_next field above always holds the LATEST value, so the
522
+ # eventual single run uses the final value.
523
+ loading_state._debounce_deadline = time.time() + debounce_ms / 1000.0
524
+ if loading_state._debounce_timer is not None:
525
+ loading_state._debounce_timer.cancel()
526
+ timer = threading.Timer(debounce_ms / 1000.0, request_render)
527
+ timer.daemon = True
528
+ loading_state._debounce_timer = timer
529
+ timer.start()
530
+ note = Note(name="Run in background, start debounce", tint=(1, 0.5, 0))
531
+ draw_state.invalidate(note=note)
532
+ else:
533
+ loading_state._debounce_deadline = None
534
+ note = Note(name="Run in background, no debounce", tint=(1, 0.5, 0))
535
+ draw_state.invalidate(note=note)
536
+ request_render()
537
+
538
+ if loading_state._run_next is not None:
539
+ deadline = loading_state._debounce_deadline
540
+ if deadline is not None and time.time() < deadline:
541
+ # Inside the quiet window - keep this draw_state dirty so the deadline
542
+ # render re-runs this frame, but DON'T request_render: the one-shot
543
+ # timer above wakes the loop exactly once when the deadline lands.
544
+ note = Note(name="new converters, Deadline", tint=(1, 0.5, 1.0), draw_state=draw_state)
545
+
546
+ draw_state.invalidate(note=note)
547
+ else:
548
+ loading_state._debounce_deadline = None
549
+ if loading_state._debounce_timer is not None:
550
+ loading_state._debounce_timer.cancel()
551
+ loading_state._debounce_timer = None
552
+
553
+ # Completion can arrive while another OS surface is active.
554
+ # Wake the cache which owns this runner, captured on the UI thread.
555
+ runner_cache = Melty.cache
556
+
557
+ def run(run_next_inner):
558
+ loading_state._loading = True
559
+ value, background_kwargs = run_next_inner
560
+ # Never run a @render_func WRAPPER on this worker thread - the wrapper
561
+ # mutates process-global Melty state (depth, unique_id, ...) on
562
+ # entry/exit, which races the main render thread. Grab the bare inner
563
+ # function: plain functions pass through unchanged.
564
+ value = getattr(value, '__wrapped__', value)
565
+ # Queue wait = arm edge → this thread actually executing (frame
566
+ # hops + thread spawn + GIL contention live in this gap).
567
+ _armed = getattr(loading_state, '_armed_t', None)
568
+ _wait_ms = (time.perf_counter() - _armed) * 1000 if _armed else -1
569
+ try:
570
+ with _pspan(f"rib: run {getattr(value, '__name__', 'task')}",
571
+ wait_ms=round(_wait_ms, 1)):
572
+ loading_state.cached_result = value(**background_kwargs)
573
+ except Exception as exc:
574
+ loading_state.error = exc
575
+ print_stack_trace(exception=exc)
576
+ finally:
577
+ loading_state._loading = False
578
+ loading_state._pending_change = True
579
+ # Completion wake for every flavor, main_thread=True included.
580
+ # main_thread once meant "ran inline, result visible this
581
+ # frame" (see the commented block below) - when it moved to
582
+ # worker threads the invalidate was never added, so a finished
583
+ # load/save was unobserved until an unrelated event fired a
584
+ # frame (the 100–600ms idle gaps on the initial-load
585
+ # timeline). The invalidate dirties this runner's tile +
586
+ # ancestors so the caller's body actually re-runs next frame -
587
+ # a dirty tile would just replay its blit past the result.
588
+ note = Note(name="Run in background complete", tint=(0.5, 1.0, 0.5), draw_state=draw_state)
589
+ # A fast load can finish before this frame commits its
590
+ # tiles. Queue the wake after that commit on the UI thread.
591
+ def wake():
592
+ runner_cache.invalidate(draw_state._tile_id, force=True, note=note)
593
+ # Hidden, zero-pixel IO hosts have no tiles to dirty.
594
+ # Their wrapper flags are the IO pump's wake signal.
595
+ current = draw_state
596
+ seen = set()
597
+ while current is not None and id(current) not in seen:
598
+ seen.add(id(current))
599
+ if current._tile_id not in runner_cache._tiles:
600
+ current._external_change = True
601
+ current = current._parent
602
+ Melty.post_to_render(wake)
603
+
604
+ #
605
+ # if Melty.frame_count < 0 or main_thread:
606
+ # run(run_next_inner=loading_state._run_next)
607
+ # loading_state._run_next = None
608
+ # else:
609
+ run_next = loading_state._run_next
610
+ if not loading_state._loading:
611
+ loading_state._run_next = None
612
+ if inline_first and loading_state.cached_result is UNSET:
613
+ # First-ever result for a caller that opted in (the initial
614
+ # file load): run synchronously so the value is visible THIS
615
+ # frame. Each async stage costs one whole render-hop before
616
+ # its result is observed; on the initial-load pipeline those
617
+ # hops (not the work, ~2ms here) are the latency. Only the
618
+ # first load is inline - reloads and every save stay async
619
+ # (the save UI must never interrupt the edit frame).
620
+ run(run_next_inner=run_next)
621
+ else:
622
+ # Named after the target func so perf-trace lines from this
623
+ # worker read as e.g. [bg:_run_chain_in] instead of [Thread-42].
624
+ _bg_name = f"bg:{getattr(run_next[0], '__name__', 'task')}"
625
+ threading.Thread(target=run, kwargs={"run_next_inner": run_next},
626
+ name=_bg_name).start()
627
+ loading_state._loading_start_frame = Melty.frame_count
628
+ if loading_state._run_next is run_next:
629
+ loading_state._run_next = None
630
+
631
+ # BUSY includes "completed, but a newer run is already queued". Completion is
632
+ # only ever REPORTED once the queue is empty (coalesced to latest-only - see
633
+ # the docstring), so a swallowed completion must surface as LOADING, never
634
+ # as idle `(False, cached_result)`: the caller can't tell idle from
635
+ # completed-and-superseded, and must attach a meaning to the busy
636
+ # state (code_file_io absorbs its own write's mtime bump on LOADING frames).
637
+ # Dropping into idle could let a save's mtime bump read back as an EXTERNAL
638
+ # change → false "changes on disk" conflict → auto-save stopped re-arming →
639
+ # the queued save wrote a stale snapshot (the old-value-during-save bug).
640
+ if loading_state._loading or (loading_state._pending_change
641
+ and loading_state._run_next is not None):
642
+ return False, LOADING
643
+
644
+ if loading_state._pending_change and loading_state._run_next is None:
645
+ loading_state._pending_change = False
646
+ # Report edge: the caller actually OBSERVES the result. total_ms - the
647
+ # run span's duration = frame-hop / wake latency, the historic silent
648
+ # cost on the initial-load pipeline.
649
+ _armed = getattr(loading_state, '_armed_t', None)
650
+ if _armed is not None:
651
+ loading_state._armed_t = None
652
+ _ptrace(f"rib: report {getattr(loading_state, '_armed_label', 'task')}",
653
+ total_ms=round((time.perf_counter() - _armed) * 1000, 1))
654
+ note = Note(name="run in background complete, new conv", tint=(0.5, 0.5, 1.0))
655
+
656
+ # draw_state.invalidate_up(max_depth=4, note=note)
657
+ request_render()
658
+ return True, loading_state.cached_result
659
+ else:
660
+ return False, loading_state.cached_result
661
+
662
+
663
+ @no_save_exclude()
664
+ @no_save("text_cache", "code_tree_cache", "address")
665
+ class CodeState(DictConversion):
666
+ def __init__(self):
667
+ super().__init__()
668
+ self.text_cache = UNSET
669
+ self.code_tree_cache = None
670
+ self.address = None
671
+ self.file_mtime = None
672
+ self.file_size = None
673
+ self._pending_save = False
674
+ # Set when the codec REFUSED a save (SaveConflict: the on-disk span
675
+ # changed during the debounced write). While set, a stale file is a
676
+ # GENUINE conflict even if the disk content is an in-process write -
677
+ # it gates the auto-write absorb in code_file_io so the conflict UI
678
+ # actually surfaces. Cleared on a successful save or a (re)load.
679
+ self._save_refused = False
680
+ # Set the frame an edit changes the buffer; consumed next frame to force a
681
+ # reconvert (so chain_in re-parses the new text and surfaces syntax errors)
682
+ # even when nothing external changed.
683
+ self._reconvert = False
684
+ self._recompiled_on_frame = None
685
+ self.recompile_result = None
686
+ # External-change indication: _loaded_externally marks an in-flight load
687
+ # that was TRIGGERED by a disk change (vs the initial fill); when it
688
+ # completes, the frame/time stamps trigger the fading "loaded from disk"
689
+ # status next to the buttons (see external_load_status).
690
+ self._loaded_externally = False
691
+ self._external_load_frame = None
692
+ self._external_load_time = None
693
+ # What the fading external-load stamp says: None = "loaded from disk";
694
+ # a manual load path may set its own message.
695
+ self._external_load_label = None
696
+
697
+ def is_file_stale(self):
698
+ if self.address is None:
699
+ return False
700
+ try:
701
+ s = self.address.path.stat()
702
+ return s.st_mtime != self.file_mtime or s.st_size != self.file_size
703
+ except OSError:
704
+ return True
705
+
706
+ def mark_file_current(self):
707
+
708
+ if self.address is None:
709
+ return
710
+ try:
711
+ s = self.address.path.stat()
712
+ self.file_mtime = s.st_mtime
713
+ self.file_size = s.st_size
714
+ except OSError:
715
+ pass
716
+
717
+ def mark_file_stale(self):
718
+ self.file_mtime = None
719
+ self.file_size = None
720
+
721
+
722
+ def _common_indent(text):
723
+ """The leading whitespace `textwrap.dedent` would strip — i.e. the indent
724
+ shared by every non-blank line, returned as the actual chars (tab-safe), or
725
+ "" if there is none. The inverse prefix for re-indenting after a round trip."""
726
+ if not isinstance(text, str):
727
+ return ""
728
+ dedented = textwrap.dedent(text)
729
+ for orig, ded in zip(text.splitlines(), dedented.splitlines()):
730
+ if orig.strip():
731
+ return orig[:len(orig) - len(ded)]
732
+ return ""
733
+
734
+
735
+ # A call site is a statement lifted from INSIDE a function body, and a
736
+ # `return` / `yield` / `await` is a syntax error at module scope even though the
737
+ # code is fine where it lives. Wrap it in a throwaway function so the parser
738
+ # accepts it: `def` for return/yield/yield from, `async def` for await. The
739
+ # synthetic def is self-marking (by name), so the reverse strips it without any
740
+ # flag to remember. Shared by string_to_cst_module (libcst) and _compile_check
741
+ # (compile) so both treat the in-function case identically.
742
+ _CALL_WRAP_NAME = "__melty_call_wrap__"
743
+ _CALL_WRAP_PREFIXES = (f"def {_CALL_WRAP_NAME}():\n", f"async def {_CALL_WRAP_NAME}():\n")
744
+
745
+ # A Decorations codec span is a bare `@deco` block, which needs a def/class after
746
+ # it to parse. Append a throwaway def so the parser accepts it. the decorators
747
+ # attach to it and the reverse (cst_module_to_string) lifts them back off, so no
748
+ # flag has to thread through. Self-marking via the synthetic def.
749
+ _DECO_WRAP_NAME = "__melty_deco_wrap__"
750
+ _DECO_WRAP_SUFFIX = f"\ndef {_DECO_WRAP_NAME}(): pass\n"
751
+
752
+
753
+ def _unwrap_call_module(module):
754
+ """Strip the synthetic def string_to_cst_module added to parse an isolated
755
+ snippet, recovering the original source at module column. A no-op for a normal
756
+ (unwrapped) module.
757
+
758
+ Two shapes: `def __melty_call_wrap__()` wraps an in-function STATEMENT (recover
759
+ its body); `def __melty_deco_wrap__()` carries a DECORATOR block (recover the
760
+ `@...` lines off its decorators). A str is the core_syntax path's "module"
761
+ (the text itself, see _melty_syntax_source): the same two wrappers come off
762
+ textually."""
763
+ if isinstance(module, str):
764
+ for prefix in _CALL_WRAP_PREFIXES:
765
+ if module.startswith(prefix):
766
+ return textwrap.dedent(module[len(prefix):])
767
+ if module.endswith(_DECO_WRAP_SUFFIX):
768
+ return module[:-len(_DECO_WRAP_SUFFIX)] + "\n"
769
+ return module
770
+ body = getattr(module, "body", None)
771
+ if body and len(body) == 1 and isinstance(body[0], cst.FunctionDef):
772
+ name = body[0].name.value
773
+ if name == _CALL_WRAP_NAME:
774
+ return cst.Module(body=list(body[0].body.body)).code
775
+ if name == _DECO_WRAP_NAME:
776
+ blank = cst.Module(body=[])
777
+ return "".join(blank.code_for_node(d) for d in body[0].decorators)
778
+ return module.code
779
+
780
+
781
+ def _melty_syntax_source(text):
782
+ """Toggles.TextEditor.melty_syntax: the chain's "module" is the (dedented)
783
+ TEXT itself — cst_module_to_dict parses a str through core_syntax. Same
784
+ wrap fallbacks as the libcst path below (a function wrapper for an
785
+ in-function statement, a throwaway def after a decorator block), checked
786
+ with ast; _unwrap_call_module strips them textually on the way out. Raises
787
+ the bare SyntaxError when nothing parses (a VALUE for _run_convert — it
788
+ carries `lineno` for the red highlight like _compile_check's)."""
789
+ import ast
790
+ if (Toggles.TextEditor.melty_scanner
791
+ and len(text) >= Toggles.TextEditor.melty_async_min_chars):
792
+ # A buffer this big is a whole file (never a snippet needing a wrapper)
793
+ # and its syntax check runs inside the scan worker, off the GIL - an
794
+ # ast.parse here would hold the render thread for ~70ms per keystroke.
795
+ return text
796
+ try:
797
+ ast.parse(text)
798
+ return text
799
+ except SyntaxError as bare_exc:
800
+ indented = textwrap.indent(text, " ")
801
+ for prefix in _CALL_WRAP_PREFIXES:
802
+ wrapped = prefix + indented
803
+ try:
804
+ ast.parse(wrapped)
805
+ return wrapped
806
+ except SyntaxError:
807
+ continue
808
+ wrapped = text.rstrip() + _DECO_WRAP_SUFFIX
809
+ try:
810
+ ast.parse(wrapped)
811
+ return wrapped
812
+ except SyntaxError:
813
+ pass
814
+ raise bare_exc
815
+
816
+
817
+ @render_func()
818
+ def string_to_cst_module(input_value, **kwargs):
819
+ # A targeted codec (a call site, a nested def/class) hands in a snippet that
820
+ # carries its original leading indentation, which cst.parse_module rejects - a
821
+ # module can't start indented ("expected an indented block"/"unexpected
822
+ # indent"). Strip the common indent before parsing; cst_module_to_string
823
+ # re-applies it on the way out so the edit splices back at its real column.
824
+ # No-op for top-level source (common indent is ""), so unchanged for the
825
+ # class/function/module codecs.
826
+ text = textwrap.dedent(input_value) if isinstance(input_value, str) else input_value
827
+ if not isinstance(text, str):
828
+ return True, cst.parse_module(text)
829
+ if Toggles.TextEditor.melty_syntax:
830
+ return True, _melty_syntax_source(text)
831
+ try:
832
+ return True, cst.parse_module(text)
833
+ except cst.ParserSyntaxError as bare_exc:
834
+ # Bare parse failed - retry wrapped in a function (then async function), the
835
+ # same fallback _compile_check uses, so an in-function statement parses.
836
+ # cst_module_to_string strips the wrapper back off via _unwrap_call_module.
837
+ indented = textwrap.indent(text, " ")
838
+ for prefix in _CALL_WRAP_PREFIXES:
839
+ try:
840
+ return True, cst.parse_module(prefix + indented)
841
+ except cst.ParserSyntaxError:
842
+ continue
843
+ # Decorator-only snippet (a Decorations codec span): a bare `@deco` needs a
844
+ # def after it. Append a throwaway one; _unwrap_call_module strips the
845
+ # decorators back off on the way out.
846
+ try:
847
+ return True, cst.parse_module(text.rstrip() + _DECO_WRAP_SUFFIX)
848
+ except cst.ParserSyntaxError:
849
+ pass
850
+ raise bare_exc # genuinely unparseable - surface the original error
851
+
852
+
853
+ @render_func()
854
+ def cst_module_to_string(input_value, indent="", **kwargs):
855
+ # Mirror of string_to_cst_module: strip any synthetic wrapper def, then re-apply
856
+ # the snippet's original indent (computed once from the whole buffer by
857
+ # convert_in_to_out and threaded down through chain_out). Empty indent and an
858
+ # unwrapped module both make this a no-op for top-level source.
859
+ code_str = _unwrap_call_module(input_value)
860
+ if indent:
861
+ code_str = textwrap.indent(code_str, indent)
862
+ return True, code_str
863
+
864
+
865
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
866
+ # ║ draw_modes - the general version of the old hardcoded code_file_io_wrapped ║
867
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
868
+
869
+ def _run_convert(chain, value, route=None, routed=None, **extra):
870
+ """Run a stateless converter chain inline — no run_chain, no threads.
871
+
872
+ Each node is called as a BARE function: render_func nodes via `__wrapped__`
873
+ (so we skip the wrapper's global-stack mutation and don't spawn draw_states),
874
+ plain @register converters directly. A node returns either (changed, value)
875
+ or a bare value; we keep only the value.
876
+
877
+ `route` mirrors run_chain's routing: it maps a node to a name, and that
878
+ node's output is stashed under the name in `routed` so downstream nodes — and
879
+ the view functions in draw_modes — can pull it by name.
880
+
881
+ EXCEPTIONS ARE VALUES. A cst parse over half-typed source raises — that is a
882
+ normal editor state, not a bug in our code — so we CATCH it and return the
883
+ exception itself in the value slot, stopping the chain, rather than letting it
884
+ propagate. The caller renders it as UI and falls back to the last good
885
+ conversion. `routed` only ever holds clean values: the failing node returns
886
+ before its route entry is written, so an Exception never lands in `routed`."""
887
+ if routed is None:
888
+ routed = {}
889
+ for node in chain:
890
+ if isinstance(node, tuple):
891
+ node, node_kwargs = node
892
+ else:
893
+ node_kwargs = {}
894
+ inner = getattr(node, '__wrapped__', node)
895
+ if not hasattr(node, "__params__"):
896
+ node.__params__ = dict(inspect.signature(inner).parameters)
897
+
898
+ accepts_var_kw = "kwargs" in node.__params__
899
+ call = {'input_value': value, **routed, **extra, **node_kwargs}
900
+
901
+ if not accepts_var_kw:
902
+ call = {k: v for k, v in call.items() if k in node.__params__}
903
+ try:
904
+ result = inner(**call)
905
+ except Exception as e:
906
+ return e, routed
907
+ if isinstance(result, tuple) and len(result) == 2:
908
+ _, value = result
909
+ else:
910
+ value = result
911
+ if route and node in route:
912
+ # A route entry is either a bare output NAME, or a tuple whose first
913
+ # element is the output name and whose remaining elements are the
914
+ # extra-input kwargs this node consumes (handled by its caller, not
915
+ # here). Either way the node's output is stashed under the name only -
916
+ # NOT under every tuple element (that used to alias jump_to/run_jedi to
917
+ # the dict and clobber them).
918
+ target = route[node]
919
+ out_name = target[0] if isinstance(target, tuple) else target
920
+ if out_name:
921
+ routed[out_name] = value
922
+
923
+ return value, routed
924
+
925
+
926
+ def _compile_check(text):
927
+ """Second-pass syntax check, catching errors libcst's lenient parser lets
928
+ through but Python's own compiler rejects — duplicate args (`def f(x, x)`),
929
+ repeated kwargs (`foo(a=1, a=1)`), etc.
930
+
931
+ Returns the SyntaxError (carrying a real `lineno` for the red highlight) or
932
+ None if it compiles clean. Runs ONLY after libcst already parsed the buffer,
933
+ so it never double-reports a plain syntax error — it only *adds* the class of
934
+ mistakes cst misses.
935
+
936
+ Dedented first because the editor can hold an indented span (a nested class
937
+ as getsourcelines returns it); `compile` rejects a leading indent the same
938
+ way `_recompile_class` handles it.
939
+
940
+ Call sites complicate this: a caller snippet is a statement lifted from INSIDE
941
+ a function body, so a `return` / `yield` / `await` / bare continuation is a
942
+ SyntaxError at module scope ("'return' outside function") even though the code
943
+ is perfectly valid where it lives. When the bare compile fails, retry the
944
+ snippet wrapped in a throwaway `def`; if THAT compiles clean the error was only
945
+ the missing function context, so report nothing. A real mistake survives the
946
+ wrap and is reported, with its line mapped back by 1 (the synthetic `def` adds
947
+ a line on top). NOTE: pure syntax/compile only — it does NOT catch undefined
948
+ names / typos (`print(myvarr)`), which are runtime NameErrors needing scope
949
+ analysis (pyflakes)."""
950
+ from meltygui.code.syntax_check import check_syntax
951
+ if isinstance(text, str) and len(text) > Toggles.TextEditor.fast_check_max_chars:
952
+ from meltygui.code.syntax_check_worker import check_isolated
953
+ return check_isolated(text, _CALL_WRAP_PREFIXES)
954
+ return check_syntax(text, _CALL_WRAP_PREFIXES)
955
+
956
+
957
+ # Process-boot timestamp for the lint/suggestion boot window: within
958
+ # _LINT_BOOT_QUIET_S of the FIRST module load, chain_in skips both passes
959
+ # (lint_deferred) so app load never pays them - the editor reschedules via
960
+ # the relint path once up. globals().get keeps the stamp across hotswap
961
+ # re-execs (module registries survive; a reset clock would re-enable the
962
+ # window on every swap).
963
+ _BOOT_T = globals().get("_BOOT_T") or time.monotonic()
964
+ _LINT_BOOT_QUIET_S = 8.0
965
+
966
+
967
+ def _region_compile_check(old, new, max_chars):
968
+ """Changed-region syntax check for buffers too big to compile whole per
969
+ keystroke. Diffs `old` → `new` by common prefix/suffix LINES, expands the
970
+ changed span to its enclosing top-level block(s) (nearest column-0 lines),
971
+ and compiles just that snippet through _compile_check (which dedents and
972
+ fake-function-wraps, so a mid-file block checks clean standalone).
973
+
974
+ Differential by design: a region cut through a triple-quoted string or a
975
+ bracketed continuation fails to compile for reasons that aren't the user's
976
+ edit — so a NEW failure only counts when the SAME region from the OLD text
977
+ compiled clean. Returns (status, err, span):
978
+ "clean" — new region compiles; no syntax error introduced here
979
+ "error" — new region fails, old was clean: err carries a REAL
980
+ SyntaxError with lineno mapped to buffer coordinates
981
+ "ambiguous" — both fail (extraction artifact, or an error predating this
982
+ edit): err is the new failure, caller decides
983
+ "skip" — no line change (span None), or region over max_chars
984
+ (span still reported: no compile ran, but the caller can
985
+ keep shifting a held error around the unchecked edit)
986
+ `span` is (start, end_old, delta): the checked block as 0-based OLD-text
987
+ line bounds (end exclusive) plus the edit's line-count delta — what a
988
+ caller needs to keep a held error from ANOTHER region alive across this
989
+ edit (clear it only inside the span; shift it by delta below the span)."""
990
+ a = old.split("\n")
991
+ b = new.split("\n")
992
+ na, nb = len(a), len(b)
993
+ from meltygui.editor.text_editor import _text_splice
994
+ edit = _text_splice(old, new)
995
+ if edit is None:
996
+ return "skip", None, None
997
+ pre, end_line = edit[4], edit[5]
998
+ # Character boundaries can sit on an unchanged empty line. Refine only
999
+ # those two rows; the common prefix/suffix already covers the rest.
1000
+ while pre < min(na, nb) and a[pre] == b[pre]:
1001
+ pre += 1
1002
+ suf = max(0, min(na - end_line - 1, na - pre, nb - pre))
1003
+ while suf < min(na - pre, nb - pre) and a[na - 1 - suf] == b[nb - 1 - suf]:
1004
+ suf += 1
1005
+ lo, hi = pre, nb - suf
1006
+ # Expand to enclosing top-level block(s): up to the nearest column-0 line
1007
+ # at/above the first changed line, down to (exclusive) the first column-0
1008
+ # line at/after the changed span. Lines outside the changed span are
1009
+ # common to both texts (prefix/suffix aligned), so the same region slices
1010
+ # out of `old` at a suffix-shifted end index.
1011
+ start = min(lo, nb - 1)
1012
+ while start > 0 and (not b[start] or b[start][0] in " \t"):
1013
+ start -= 1
1014
+ end = hi
1015
+ while end < nb and (not b[end] or b[end][0] in " \t"):
1016
+ end += 1
1017
+ end_old = end + (na - nb)
1018
+ span = (start, max(start, end_old), nb - na)
1019
+ region_new = "\n".join(b[start:end])
1020
+ region_old = "\n".join(a[start:max(start, end_old)])
1021
+ if len(region_new) > max_chars or len(region_old) > max_chars:
1022
+ return "skip", None, span
1023
+ err_new = _compile_check(region_new)
1024
+ if err_new is None:
1025
+ return "clean", None, span
1026
+ if getattr(err_new, "lineno", None):
1027
+ err_new.lineno = start + err_new.lineno # region → buffer line
1028
+ return (("error" if _compile_check(region_old) is None else "ambiguous"),
1029
+ err_new, span)
1030
+
1031
+
1032
+ def _safe_newline_delta(last_good, cur):
1033
+ """True when `cur` differs from `last_good` ONLY by added/removed blank
1034
+ (whitespace-only) lines, or is byte-identical — the safe mutation class:
1035
+ it cannot change the parse structure or introduce a syntax error (blank
1036
+ lines are ignored by the grammar; inside a string literal a newline is
1037
+ still valid syntax), so chain_in can skip its whole reparse for it.
1038
+
1039
+ Deliberately O(buffer): two C-speed splits + one list compare, microseconds
1040
+ against the 150-550ms GIL-held parse it avoids. This is edit CLASSIFICATION
1041
+ on a background worker replacing strictly larger work — not the render-path
1042
+ content-hash cache invalidation CLAUDE.md forbids."""
1043
+ if not last_good or not cur:
1044
+ return False
1045
+ if last_good == cur:
1046
+ return True # byte-identical echo - nothing to reparse
1047
+ a = [l for l in last_good.split("\n") if l.strip()]
1048
+ b = [l for l in cur.split("\n") if l.strip()]
1049
+ return a == b
1050
+
1051
+
1052
+ def _run_chain_in(input_value, chain=None, _src_gen=None, lint_path=None,
1053
+ lint_span=False, _last_good_src=None, _last_good_routed=None,
1054
+ **extra):
1055
+ """Background entry point for the forward (chain_in) conversion.
1056
+
1057
+ A plain module-level function (NOT a @render_func) so run_in_background can
1058
+ call it directly on its worker thread without touching any imgui/Melty global
1059
+ state. Runs the whole chain via _run_convert and returns the full `routed`
1060
+ dict — every column's input in one shared payload. The result/exception is
1061
+ folded back into ModesState on the main thread when the worker completes.
1062
+
1063
+ `_src_gen` (the origin-edit generation of the source being parsed) is pulled out
1064
+ of the kwargs so it isn't forwarded to the chain nodes, then echoed back in the
1065
+ payload — bound to THIS worker's input snapshot, so the caller learns which
1066
+ generation the finished parse actually reflects (not whatever the source is by the
1067
+ time the worker returns)."""
1068
+ notify(f"_run_chain_in: start", tag="chain_in")
1069
+ # Imported once for the WHOLE body: a branch-local import would make the
1070
+ # name function-local everywhere, and the lint section's call then throws
1071
+ # UnboundLocalError whenever the incremental branch skipped the import.
1072
+ from meltygui.code.libcst_conversion import _yield_to_ui
1073
+
1074
+ # ── libcst-dict cache over the chain parse ────────────────────────────────────
1075
+ # Only for a PRISTINE disk buffer: DiskCodec.load stamps the loaded text
1076
+ # with the disk mtime it reflects (DiskSpanText); any edit decays it to a
1077
+ # plain str, so provenance - not content comparison - gates the cache. Only
1078
+ # the canonical [string_to_cst_module, cst_module_to_dict] chain counts,
1079
+ # its output routed under the final node's route name. A run_jedi flag
1080
+ # (the explicit Jedi click) always runs the real pass.
1081
+ _disk_mtime = getattr(input_value, "_disk_mtime", None)
1082
+ _disk_span = getattr(input_value, "_disk_span", None)
1083
+ _tail = chain[-1] if chain else None
1084
+ if isinstance(_tail, tuple):
1085
+ _tail = _tail[0]
1086
+ _cacheable = (_disk_mtime is not None and _disk_span is not None
1087
+ and not extra.get("run_jedi")
1088
+ and getattr(_tail, "__name__", "") == "cst_module_to_dict")
1089
+ _route = extra.get("route") or {}
1090
+ _out_target = _route.get(_tail)
1091
+ _out_name = _out_target[0] if isinstance(_out_target, tuple) else _out_target
1092
+ if _cacheable and _out_name:
1093
+ _gp = chain_parse_cache_get(_disk_span, _disk_mtime)
1094
+ if _gp is not None:
1095
+ notify(f"cst cache hit: {Path(_disk_span[0]).name}"
1096
+ f" [{_disk_span[1]}:{_disk_span[2]}]",
1097
+ tag="cst_cache", tint=(0.4, 0.9, 0.4))
1098
+ # The cache only ever holds CLEAN parses, so error stays None. The
1099
+ # lint pass + the import-suggestion scan read the LIVE process,
1100
+ # so their findings aren't cacheable - but this branch runs INLINE
1101
+ # on the render thread at app load (inline_first with guaranteed
1102
+ # cache hits), so paying a whole-file ast parse + tokenize-based
1103
+ # suggestion there is just the very stall to avoid. Defer instead:
1104
+ # lint_deferred rides the payload, the fold stamps ModesState, and
1105
+ # the editor schedules a _run_relint (worker-side, input-quiet
1106
+ # parked) once the app is up.
1107
+ return {"routed": {_out_name: _gp}, "error": None, "lint": [],
1108
+ "imports": {}, "lint_deferred": lint_path is not None,
1109
+ "_src_gen": _src_gen, "src_good": input_value}
1110
+
1111
+ # ── Safe-mutation skip ─────────────────────────────────────────────────
1112
+ # A newline-only edit vs the last successfully parsed source can't change
1113
+ # the parse structure or introduce a syntax error - rerunning the GIL-held
1114
+ # libcst parse + dict conversion + lint for it only convoys the render
1115
+ # thread. Skip the full chain: the fold sites keep the held result and the
1116
+ # src_good baseline (the good source), so the first content edit afterwards
1117
+ # diffs non-safe against it and runs the one full parse it always would
1118
+ # have. Position consumers tolerate a shifted window (the usage graph
1119
+ # pure-shift-patches per keystroke; error/lint markers sit stale-hidden
1120
+ # mid-edit; the definition's incremental fast path owns the responsive marker). A
1121
+ # run_jedi pulse (explicit Index click) always runs the real pass.
1122
+ # EXPERIMENT (Toggles.TextEditor.freeze_cst_dict): once a baseline parse
1123
+ # exists, answer EVERY reconvert with a safe-skip - the parse/conversion
1124
+ # never re-runs on edits, so the view runs on the frozen first good tree.
1125
+ # The Index pulse still forces a real pass (explicit user action, and the
1126
+ # one way to manually refresh the frozen tree while editing).
1127
+ if (Toggles.TextEditor.freeze_cst_dict
1128
+ and not extra.get("run_jedi")
1129
+ and _last_good_src is not None):
1130
+ notify("chain_in: cst dict FROZEN — reconvert skipped", tag="chain_in")
1131
+ return {"routed": {}, "error": None, "lint": [], "imports": {},
1132
+ "safe_skip": True, "lint_deferred": False,
1133
+ "_src_gen": _src_gen, "src_good": None}
1134
+
1135
+ # Narrowed to the byte-identical echo ONLY: blank-line edits used to take
1136
+ # this skip too, but the skip keeps the held parse's spans UNSHIFTED - the
1137
+ # editor's washes tolerate that (they remap editor-side), while the
1138
+ # live-view markers anchor directly off gp _child_spans and stayed pinned
1139
+ # to stale lines through any amount of Enter-typing. Blank-line edits now
1140
+ # fall through to the incremental merge below, which shifts every span
1141
+ # correctly for ~50-100ms per debounced burst.
1142
+ if (Toggles.TextEditor.skip_reparse_on_blank_edits
1143
+ and not extra.get("run_jedi")
1144
+ and isinstance(input_value, str)
1145
+ and _last_good_src is not None and _last_good_src == input_value):
1146
+ notify("chain_in: identical echo — reparse skipped", tag="chain_in")
1147
+ return {"routed": {}, "error": None, "lint": [], "imports": {},
1148
+ "safe_skip": True, "lint_deferred": False,
1149
+ "_src_gen": _src_gen, "src_good": None}
1150
+
1151
+ # ── Incremental cst→dict (Toggles.TextEditor.incremental_cst_parse) ─────
1152
+ # In the canonical chain with a previous good parse available, try the
1153
+ # O(edited statements) merge (cst_dict_incremental_update): re-convert
1154
+ # only the changed top-level statements and splice into the old parse -
1155
+ # skipping the 150-550ms whole-buffer libcst parse + dict conversion.
1156
+ # None (any doubt: header/footer present, over-sized region, verification
1157
+ # failure) falls through to the full conversion below.
1158
+ _inc_gp = None
1159
+ if (Toggles.TextEditor.incremental_cst_parse
1160
+ and not extra.get("run_jedi")
1161
+ and isinstance(input_value, str) and _last_good_src
1162
+ and _last_good_routed is not None and _out_name
1163
+ and getattr(_tail, "__name__", "") == "cst_module_to_dict"):
1164
+ _prev_gp = _last_good_routed.get(_out_name)
1165
+ if _prev_gp is not None:
1166
+ from meltygui.core.diagnostics.notifications import lag_span
1167
+ _prev_melty = _prev_gp.get("__origin__") is not None
1168
+ if _prev_melty and Toggles.TextEditor.melty_syntax:
1169
+ # Increment incremental parse: re-parse only the top-level statements
1170
+ # the edit touched and splice them into a NEW root that reuses
1171
+ # every unchanged value object of the held tree (draw calls
1172
+ # survive) - the entire tree is bubbling-wrapped, a mutation
1173
+ # would trigger as a user edit; it falls back to a full parse
1174
+ # (reparse_reusing) for header/tail edits. A broken keystroke
1175
+ # falls through to the full path, which is what reports the error.
1176
+ from meltygui.code.core_syntax import reparse_incremental
1177
+ try:
1178
+ with lag_span("melty_syntax reparse", 30):
1179
+ _inc_gp = reparse_incremental(_prev_gp, input_value)
1180
+ except SyntaxError:
1181
+ _inc_gp = None
1182
+ elif not _prev_melty and not Toggles.TextEditor.melty_syntax:
1183
+ from meltygui.code.libcst_conversion import cst_dict_incremental_update
1184
+ with lag_span("incremental cst merge", 30):
1185
+ _inc_gp = cst_dict_incremental_update(
1186
+ _prev_gp, _last_good_src, input_value)
1187
+ # else: the parser toggle flipped since the held parse - full reconvert.
1188
+
1189
+ if _inc_gp is not None:
1190
+ notify("chain_in: incremental cst merge", tag="chain_in")
1191
+ result, routed = _inc_gp, {_out_name: _inc_gp}
1192
+ parse_failed = False
1193
+ else:
1194
+ # Park BEFORE the libcst parse: string_to_cst_module is 150–550ms of
1195
+ # module-internal GIL-held CPU with no yield points inside - a run
1196
+ # launching just as the user resumes typing plowed through it and convoyed
1197
+ # the render thread (the residual 145–750ms frames after frame-busy
1198
+ # parking landed everywhere else). Waiting for input HERE turns that into
1199
+ # a parse that runs while the user is idle. No-op on the inline-first
1200
+ # (render-thread) path - _yield_to_ui never sleeps the render thread worker.
1201
+ _yield_to_ui()
1202
+
1203
+ result, routed = _run_convert(chain, input_value, **extra)
1204
+ parse_failed = isinstance(result, Exception)
1205
+ error = result if parse_failed else None
1206
+ # cst parsed clean - run the compiler check, to surface the syntax errors libcst
1207
+ # is too lenient to flag (duplicate args/kwargs, ...). Same red-highlight path.
1208
+ if error is None and isinstance(input_value, str):
1209
+ error = _compile_check(input_value)
1210
+ # Mid-edit resiliency: a broken keystroke must not blank the structured
1211
+ # views / usage sites / definition tiling downstream. When the input differs
1212
+ # from the last successfully parsed source (`_last_good_src`, threaded from
1213
+ # ModesState) by just ONE line - the line being typed - re-run the conversion
1214
+ # with that line blanked. Line count is preserved, so every other line's
1215
+ # snippet, usage sites and washes stay position-accurate. The ORIGINAL error
1216
+ # still reports (the red-line highlight is truthful) and last_good stays
1217
+ # None (the real input never parsed, so it must not become the baseline).
1218
+ # Only for a failed PARSE - a compile-check-only error still has an exact
1219
+ # parse of the real text, which matches the blanked variant.
1220
+ if parse_failed and isinstance(input_value, str):
1221
+ patched = _blank_line_variant(_last_good_src, input_value)
1222
+ if patched is not None:
1223
+ repaired, routed_repaired = _run_convert(chain, patched, **extra)
1224
+ if not isinstance(repaired, Exception) and _compile_check(patched) is None:
1225
+ routed = routed_repaired
1226
+ # Compiled clean - run the static "will this RUN" pass too: undefined names +
1227
+ # call-signature mismatches (code_checks.check_source). Only when the host
1228
+ # declared a lint_path (a WHOLE-FILE buffer - a span buffer would flag every
1229
+ # module-level import it can't see). Same background thread, [(line, msg)].
1230
+ # Lint and import suggestions, parked behind input-quiet first (same
1231
+ # frame-busy protection as the parse above): both passes do whole-buffer
1232
+ # / whole-file work (lint-mode check_source and the suggestion scan each
1233
+ # consult _module_text_binds - a possible whole-file ast parse - and the
1234
+ # scan tokenizes the buffer), and running them mid-typing-burst
1235
+ # GIL-convoys the render thread. No-op on the inline render-thread path.
1236
+ # During APP LOAD (boot window) they're skipped outright - every host's
1237
+ # chain parse lands in one GIL-hungry burst there - and delayed through
1238
+ # the same lint_deferred → relint on the cache-hit branch above.
1239
+ lint = []
1240
+ imports = {}
1241
+ _lintable = lint_path is not None and isinstance(input_value, str)
1242
+ _defer_lint = (_lintable
1243
+ and time.monotonic() - _BOOT_T < _LINT_BOOT_QUIET_S)
1244
+ if _lintable and not _defer_lint:
1245
+ _yield_to_ui()
1246
+ _lint_fn = (check_source_incremental
1247
+ if Toggles.TextEditor.incremental_lint else check_source)
1248
+ # The lint stays on the input-quiet worker, including unedited files.
1249
+ if (error is None and Toggles.TextEditor.check_syntax_errors
1250
+ and Toggles.TextEditor.enable_lint
1251
+ and (Toggles.TextEditor.incremental_lint
1252
+ or len(input_value) <= Toggles.TextEditor.lint_max_chars)):
1253
+ try:
1254
+ lint = _lint_fn(input_value, path=lint_path,
1255
+ only_missing_imports=lint_span)
1256
+ except Exception:
1257
+ lint = []
1258
+ # Import suggestions - a SEPARATE channel from errors, computed
1259
+ # regardless of parse state (tokenize-based, so a half-typed `json.`
1260
+ # line still yields its fix - the IDE type-`json.`-press-Enter flow).
1261
+ try:
1262
+ imports = (collect_import_suggestions(input_value, path=lint_path)
1263
+ if Toggles.TextEditor.enable_import_scan else {})
1264
+ except Exception:
1265
+ imports = {}
1266
+ # Store the finished parse for the next boot: pristine disk input (see the
1267
+ # provenance gate above) + a clean parse/compile only, so a cache hit can
1268
+ # skip the expensive pass. One dumps (~50ms for a large span) on this
1269
+ # background thread buys every editor startup a ~25ms load instead of the
1270
+ # full seconds.
1271
+ if _cacheable and _out_name and error is None:
1272
+ chain_parse_cache_put(_disk_span, _disk_mtime, routed.get(_out_name))
1273
+
1274
+ return {"routed": routed, "error": error, "lint": lint, "imports": imports,
1275
+ "lint_deferred": _defer_lint, "_src_gen": _src_gen,
1276
+ "src_good": input_value if error is None else None}
1277
+
1278
+
1279
+ def _run_relint(input_value=None, lint_path=None, lint_span=False):
1280
+ """Background lint-only pass over a span buffer — no reparse, no chain.
1281
+
1282
+ What the lint and the import suggestions report depends on the FILE's
1283
+ pending text (code_checks._module_text_binds), so an import added/removed
1284
+ in another view — or a reverted pending entry — changes the right answer
1285
+ without any edit to this span. PendingSave.queue_save kicks the file's
1286
+ hosts (_kick_relint) and draw_text_from_code_cache runs this to refresh
1287
+ ModesState.last_lint / last_imports alone."""
1288
+ try:
1289
+ if not isinstance(input_value, str) or lint_path is None:
1290
+ return {"lint": [], "imports": {}}
1291
+ # Park until input goes quiet - a kicked relint must never be GIL
1292
+ # convoy an actively-typing render thread (no-op when idle).
1293
+ from meltygui.code.libcst_conversion import _yield_to_ui
1294
+ _yield_to_ui()
1295
+ # Over the lint cap the O(buffer) passes downgrade: no check_source,
1296
+ # and the import rescan runs the incremental step instead of full -
1297
+ # see Toggles.TextEditor.lint_max_chars for the trade-off.
1298
+ _small = len(input_value) <= Toggles.TextEditor.lint_max_chars
1299
+ _inc = Toggles.TextEditor.incremental_lint
1300
+ lint = []
1301
+ if (Toggles.TextEditor.check_syntax_errors
1302
+ and Toggles.TextEditor.enable_lint and (_small or _inc)):
1303
+ _lint_fn = check_source_incremental if _inc else check_source
1304
+ try:
1305
+ lint = _lint_fn(input_value, path=lint_path,
1306
+ only_missing_imports=lint_span)
1307
+ except Exception:
1308
+ lint = []
1309
+ # Suggestions alone - tokenize-based, runs through a mid-edit
1310
+ # syntax error (check_source returns [] on those; that's fine, the
1311
+ # error marker itself comes from the parse pass, not from here).
1312
+ # full=True: a relint fires because the FILE's pending state changed
1313
+ # (import added/removed/reverted), which invalidates the incremental
1314
+ # scan's cached verdicts - rescan from scratch.
1315
+ imports = (collect_import_suggestions(input_value, path=lint_path,
1316
+ full=_small)
1317
+ if Toggles.TextEditor.enable_import_scan else {})
1318
+ return {"lint": lint, "imports": imports}
1319
+ except Exception:
1320
+ return {"lint": [], "imports": {}}
1321
+
1322
+
1323
+ def _kick_relint(path):
1324
+ """Flag every code host linting `path` to re-run its lint pass. Called
1325
+ from PendingSave.queue_save (any pending edit to the file may change what
1326
+ the lint should report) — which fires per queued keystroke save and
1327
+ redundantly during chain_out echo bursts, so this must stay CHEAP:
1328
+ setting the flag is free and idempotent; the consumer wake (which
1329
+ invalidates the cached editor bodies so the flag is actually seen) is
1330
+ rate-limited per host. An editor being typed in re-runs anyway and
1331
+ consumes the flag without the wake; the wake only matters for the
1332
+ idle-editor case (an import reverted in the pending window), where one
1333
+ wake per second is plenty."""
1334
+ target = str(path)
1335
+ woke = False
1336
+ now = time.monotonic()
1337
+ for _sh, dh in list(_code_host_cache.values()):
1338
+ lp = (dh.child_kwargs.get("run_chain_kwargs") or {}).get("lint_path")
1339
+ if lp is None:
1340
+ continue
1341
+ rp = getattr(dh, "_lint_rp", None)
1342
+ if rp is None:
1343
+ try:
1344
+ rp = str(Path(lp).resolve())
1345
+ except OSError:
1346
+ rp = lp
1347
+ dh._lint_rp = rp
1348
+ if rp != target:
1349
+ continue
1350
+ dh._relint_pending = True
1351
+ if now - getattr(dh, "_last_relint_notify", 0.0) >= 1.0:
1352
+ dh._last_relint_notify = now
1353
+ woke = True
1354
+ try:
1355
+ dh._notify_consumers(name="relint kick")
1356
+ except Exception:
1357
+ pass
1358
+ if woke:
1359
+ request_render()
1360
+
1361
+
1362
+ def _run_chain_out(input_value, chain=None, _out_gen=None, **extra):
1363
+ """Background entry point for the reverse (chain_out) conversion.
1364
+
1365
+ The mirror of _run_chain_in: a plain module-level function (NOT a
1366
+ @render_func) so run_in_background can call it on its worker thread.
1367
+ chain_out is `dict → cst → str` — it REBUILDS and re-serializes the whole
1368
+ module (O(buffer)), which is exactly the work that pegged the render loop
1369
+ when it ran inline on every structured edit. Returns {"value": text} on
1370
+ success or {"error": exc} (a half-typed structured edit can fail to round-
1371
+ trip; we surface it as a value rather than letting it propagate, same as
1372
+ chain_in).
1373
+
1374
+ `**extra` (e.g. `indent`) is forwarded to every node so cst_module_to_string
1375
+ can re-apply the snippet's leading indent stripped by string_to_cst_module —
1376
+ nodes without a matching param ignore it (_run_convert filters by signature).
1377
+
1378
+ `_out_gen` (the origin-edit generation of the structured edit being serialized) is
1379
+ pulled out so it isn't forwarded to the nodes, then echoed back in the payload —
1380
+ bound to this worker's snapshot, so the produced source string can be tagged with the
1381
+ edit frame that made it (used to recognize and order its chain_in echo)."""
1382
+ notify(f"_run_chain_out: start", tag="chain_out")
1383
+
1384
+ with _pspan("chain_out: worker", min_ms=5.0):
1385
+ result, _ = _run_convert(chain, input_value, **extra)
1386
+ if isinstance(result, Exception):
1387
+ return {"error": result, "_out_gen": _out_gen}
1388
+ return {"value": result, "_out_gen": _out_gen}
1389
+
1390
+
1391
+ # Buffers up to this size take the inline-first parse path in
1392
+ # convert_in_and_out_value (first result only - see the call site). 128KB
1393
+ # covers every practical editor span (text_editor.py's draw_text is ~102KB,
1394
+ # 215ms of parse+compile); beyond it the first parse runs async so a
1395
+ # pathological buffer can't freeze its first frame for seconds.
1396
+ _INLINE_FIRST_PARSE_MAX_CHARS = 128 * 1024
1397
+ # background_load_*' inline opt-out: small enough that a window-drag
1398
+ # hidden-host load stays an invisible few ms (a 16KB span parses ~10ms).
1399
+ _BG_INLINE_FIRST_PARSE_MAX_CHARS = 16 * 1024
1400
+
1401
+ # Typing debounce for chain_in re-parses: every keystroke fires a START edge,
1402
+ # and with no debounce a big buffer queues a full str→dict→convert per key -
1403
+ # the workers stack up, round-robin the GIL with the render thread, and a
1404
+ # normally-15ms render-thread compute measures seconds of wall time (first 3s
1405
+ # frame of 2026-07-31). 300ms sits above the inter-key gap of fast typing, so
1406
+ # each burst coalesces to ONE reparse when the input goes quiet; first parses
1407
+ # are exempt (run_in_background zeroes debounce until a first result returns).
1408
+ # Tunable live via Toggles.TextEditor.parse_debounce_ms (this is the fallback
1409
+ # default) - the same knob gates the symbol-usage recompute.
1410
+ _CHAIN_IN_DEBOUNCE_MS = 300
1411
+
1412
+
1413
+ def _chain_in_debounce_ms(input_value=None):
1414
+ """Live-read the typing debounce for this buffer; falls back to the module
1415
+ default. Buffers at or under Toggles.TextEditor.small_file_max_chars take
1416
+ the shorter small_file_debounce_ms — a few-ms parse doesn't need the long
1417
+ coalescing window the big-buffer default exists for."""
1418
+ cap = Toggles.TextEditor.small_file_max_chars
1419
+ if cap and isinstance(input_value, str) and len(input_value) <= cap:
1420
+ return Toggles.TextEditor.small_file_debounce_ms
1421
+ return Toggles.TextEditor.parse_debounce_ms
1422
+
1423
+
1424
+ class ModesState:
1425
+ """Per-window scratch for draw_modes.
1426
+
1427
+ chain_in (str → cst → dict, ...) is O(buffer) and runs on a BACKGROUND thread
1428
+ via run_in_background, so it can't block the render loop. This holds the
1429
+ cross-frame state that makes that work while keeping every column in sync:
1430
+
1431
+ last_good — the routed outputs of the last conversion that SUCCEEDED. Every
1432
+ selected column reads the SAME last_good, so they never drift apart. While a
1433
+ fresh conversion is in flight (or one throws on half-typed source) the views
1434
+ keep rendering off this snapshot instead of blanking out.
1435
+ last_error — the parse/compile error from the last run, or None when clean.
1436
+ last_lint — [(line, msg)] from the static name/signature pass over the same
1437
+ run (code_checks.check_source); [] when clean or when the buffer isn't a
1438
+ lintable whole file (no lint_path on the host)."""
1439
+
1440
+ def __init__(self):
1441
+ self.last_good = {}
1442
+ self.last_error = None
1443
+ self.last_lint = []
1444
+ # {1-based line: [import statements]} from the separate suggestions
1445
+ # channel (get_import_suggestions) - the editor's Alt+Enter data.
1446
+ # Swapped per finished run, never mutated in place (identity keys the
1447
+ # editor's applied-fix reset). Read with getattr (pre-hotswap
1448
+ # instances persist on draw_states).
1449
+ self.last_imports = {}
1450
+ # True when the last chain_in SKIPPED the lint/suggestions pass (the
1451
+ # inline cst-cache-hit path at app load) - the editor turns this into
1452
+ # a deferred _run_relint once the boot delay passes.
1453
+ self._lint_deferred = False
1454
+ # Round-trip generation tracking (kills the value-flicker). Every conversion
1455
+ # carries the Melty.frame_count of the LOCAL EDIT that originated it, so a
1456
+ # chain-in result can be ordered against the host's latest edit and a stale parse
1457
+ # rejected. `echo_str` is the exact string object our LOCAL chain_out produced;
1458
+ # when it comes back as the_in's input (by identity) we know the parse reflects
1459
+ # `echo_gen` (that edit's frame) - anything else is an external change (as of now).
1460
+ self.echo_str = None
1461
+ self.echo_gen = 0
1462
+ # The last source string that parsed clean - the diff against for the
1463
+ # blank-line repair in _run_chain_in. Read with getattr (instances
1464
+ # created before a hotfix added this field persist on draw_modes).
1465
+ self.last_good_src = None
1466
+
1467
+
1468
+ def compute_height(draw_state):
1469
+ return None
1470
+ # return min(draw_state., 400)
1471
+
1472
+
1473
+ @render_func(use_cache=True, show_bg=False, selectable=False, disable_scroll=True,
1474
+ shadow=False, indent_size=0, with_footer=None, fill_height=False, temp=True)
1475
+ def convert_in_and_out(input_value, draw_state, view_func=None, chain_in=None, chain_out=None,
1476
+ run_chain_kwargs=None, route=None, modes_state: ModesState = None,
1477
+ external_change=False, child_kwargs=None, unique=0, **kwargs):
1478
+ """Conversion half of the old draw_modes — chains only, no tabs.
1479
+
1480
+ Runs `chain_in` ONCE on a background worker (str -> cst -> dict), shares its
1481
+ outputs across every column via `routed`, hands rendering off to the injected
1482
+ `view_func` (draw_with_view_funcs), then runs the reverse `chain_out` on a
1483
+ background worker when that view reports a CONVERTED (structured) edit.
1484
+
1485
+ Routing is the indirection that keeps this view-agnostic — convert_in_and_out
1486
+ names NONE of the extra inputs it threads through:
1487
+ * `route[node]` (a name, or a tuple `(out_name, *input_names)`) names where a
1488
+ chain node's output lands in `routed`, and which of the caller's kwargs the
1489
+ node consumes. Those declared inputs are forwarded, by name from `route`
1490
+ alone, into the chain payload (`run_chain_kwargs`) AND into `routed` so the
1491
+ columns can pick them up.
1492
+ * `changed` is the chain_in trigger, supplied whole by code_file_io (load /
1493
+ external edit / the Index pulse) — we never diff the text or name an input.
1494
+ * draw_with_view_funcs reads the same `route`/`routed` to feed each column."""
1495
+ if child_kwargs is None:
1496
+ child_kwargs = {}
1497
+ if route is None:
1498
+ route = {}
1499
+
1500
+ # Forward every kwarg a route entry DECLARES (the tuple tail after the output
1501
+ # name) from the caller's kwargs into one payload - agnostically; we look the
1502
+ # names up from `route`, never spell them out. This seeds the chain inputs and
1503
+ # the values the columns read.
1504
+ forwarded = dict(run_chain_kwargs) if run_chain_kwargs else {}
1505
+ for target in route.values():
1506
+ if isinstance(target, tuple):
1507
+ for arg_name in target[1:]:
1508
+ if arg_name in kwargs:
1509
+ forwarded[arg_name] = kwargs[arg_name]
1510
+
1511
+ # last_good only ever holds CLEAN values, so copying it here means a column
1512
+ # keeps rendering the prior good parse while a fresh chain_in is in flight or
1513
+ # fails on badly-typed source. The forwarded inputs ride alongside it so views
1514
+ # (draw_text <- draw_input) get them without being hand-fed.
1515
+ routed = dict(modes_state.last_good)
1516
+ routed['root_input'] = kwargs.get("root_input", None)
1517
+ routed.update(forwarded)
1518
+
1519
+ chain_in_error = modes_state.last_error
1520
+ if chain_in:
1521
+ # Fresh dict per run (run_in_background snapshots it as _run_kwargs): never
1522
+ # reuse it for chain_out below, or a deferred chain_in run reads back
1523
+ # chain_out's mutated values.
1524
+ chain_in_kwargs = {**forwarded, "input_value": input_value,
1525
+ "chain": chain_in, "route": route,
1526
+ "_last_good_routed": modes_state.last_good,
1527
+ "_last_good_src": getattr(modes_state, "last_good_src", None)}
1528
+ # `changed` is the only trigger - code_file_io rolls load / external edit /
1529
+ # the Index pulse into it, so we never diff the text or sniff inputs here.
1530
+ # inline_first only means a guaranteed cst cache hit (a ~26ms loads); the
1531
+ # first paint lands fully formed instead of paying the async frame-hop
1532
+ # tax. A genuine change (miss) stays on the worker as before.
1533
+ inline = (not chain_in_kwargs.get("run_jedi")
1534
+ and chain_parse_cache_has(getattr(input_value, "_disk_span", None),
1535
+ getattr(input_value, "_disk_mtime", None)))
1536
+ finished, payload = run_in_background(
1537
+ _run_chain_in,
1538
+ child_kwargs=chain_in_kwargs,
1539
+ name=f"chain_in{unique}", start=external_change, inline_first=inline,
1540
+ debounce_ms=_chain_in_debounce_ms(input_value))
1541
+ if finished and isinstance(payload, dict) and payload.get("safe_skip"):
1542
+ # Newline-only edit: the worker skipped the chain because the buffer is
1543
+ # the last good source plus/minus blank lines, so it's clean. Keep
1544
+ # the held parse / lint / imports / baseline and just clear any
1545
+ # lingering error (the broken line was reverted, not reparsed).
1546
+ modes_state.last_error = None
1547
+ chain_in_error = None
1548
+ elif finished and isinstance(payload, dict):
1549
+ # Fold the completed outputs into the shared snapshot AND this
1550
+ # frame's routed (so the columns see the good values immediately).
1551
+ modes_state.last_error = payload.get("error")
1552
+ modes_state.last_lint = payload.get("lint") or []
1553
+ modes_state.last_imports = payload.get("imports") or {}
1554
+ modes_state._lint_deferred = bool(payload.get("lint_deferred"))
1555
+ if payload.get("src_good") is not None:
1556
+ modes_state.last_good_src = payload["src_good"]
1557
+ for name, val in payload["routed"].items():
1558
+ modes_state.last_good[name] = val
1559
+ routed[name] = val
1560
+ chain_in_error = modes_state.last_error
1561
+
1562
+ out_changed, out_value = False, input_value
1563
+
1564
+ recompile_error = kwargs.get('error')
1565
+ if isinstance(recompile_error, SyntaxError) and chain_in_error is None:
1566
+ recompile_error = None # buffer parses again → the recompile syntax error is fixe
1567
+ routed['error'] = recompile_error or chain_in_error
1568
+
1569
+ # Fresh dict - don't mutate the shared global child_kwargs. A raw text edit
1570
+ # comes straight back as the new text; a converted (structured) edit is
1571
+ # stashed on `routed` for chain_out below.
1572
+ # view_kwargs = {**child_kwargs, 'route': route, 'routed': routed}
1573
+ child_kwargs['routed'] = routed
1574
+
1575
+ raw_changed, raw_value = view_func(input_value=input_value, **child_kwargs)
1576
+ if raw_changed:
1577
+ out_changed, out_value = True, raw_value
1578
+ # draw_state.invalidate_up(max_depth=3)
1579
+
1580
+ converted_edit = routed.pop('converted_edit', UNSET)
1581
+
1582
+ # chain_out in a BACKGROUND thread - the mirror of chain_in. Started ONLY by a
1583
+ # real structured edit from above (converted_edit set), never by chain_in's
1584
+ # output, so there's no ping-pong. Called every frame so a conversion queued
1585
+ # by a just-finished edit still drains; `start` is still the trigger flag.
1586
+ if chain_out:
1587
+ co_start = converted_edit is not UNSET
1588
+ # Re-indent the edited snippet to the buffer's original column. The buffer
1589
+ # (input_value) is the source of truth for indentation; string_to_cst_module
1590
+ # dedented to parse, so chain_out must restore it. Computed here (not inside
1591
+ # the chain) because only the live buffer knows the indent; "" for top-level
1592
+ # source, so a no indent for all class/function/module codecs.
1593
+ indent = _common_indent(input_value) if co_start else ""
1594
+ co_changed, co_payload = run_in_background(
1595
+ _run_chain_out,
1596
+ child_kwargs={"input_value": converted_edit if co_start else None,
1597
+ "chain": chain_out, "indent": indent},
1598
+ name=f"chain_out{unique}", start=co_start)
1599
+ if co_changed and isinstance(co_payload, dict):
1600
+ if co_payload.get("error") is not None:
1601
+ imgui.text_colored(f" chain_out: {co_payload['error']}", 1.0, 0.5, 0.0)
1602
+ elif "value" in co_payload:
1603
+ out_changed, out_value = True, co_payload["value"]
1604
+
1605
+ return out_changed, out_value
1606
+
1607
+
1608
+ @render_func(use_cache=True, show_bg=False, selectable=False, disable_scroll=True,
1609
+ shadow=False, indent_size=0, with_footer=None, temp=True)
1610
+ def convert_in_and_out_value(input_value, draw_state, view_func=None, chain_in=None, chain_out=None,
1611
+ run_chain_kwargs=None, route=None, modes_state: ModesState = None, temp=True,
1612
+ external_change=False, child_kwargs=None, unique=0,
1613
+ background_load=False, **kwargs):
1614
+ """Like `convert_in_and_out`, but hands the view_func the chain_in OUTPUT directly.
1615
+
1616
+ IDENTICAL background processing to `convert_in_and_out` — chain_in and chain_out
1617
+ both run on the `run_in_background` worker, same `modes_state.last_good` snapshot,
1618
+ same error handling. The ONE difference is the view_func contract:
1619
+
1620
+ convert_in_and_out view_func(input_value=<source TEXT>, routed={code_dict: tree})
1621
+ a structured edit comes BACK via routed['converted_edit'].
1622
+ convert_in_and_out_value view_func(input_value=<chain_in's output, the TREE>)
1623
+ the view_func's RETURN is the structured edit → chain_out.
1624
+
1625
+ This is the shape a RenderHost proxy wants: it gets the parsed value as its own
1626
+ `input_value` (already a dict — no `routed` side-channel, no `materialize_from`)
1627
+ and returns the edited value, which goes straight to chain_out. The legacy
1628
+ `convert_in_and_out` + `draw_with_view_funcs` (text|tree tabs) is untouched, so the
1629
+ NEW_CODE views keep working."""
1630
+ if child_kwargs is None:
1631
+ child_kwargs = {}
1632
+ if route is None:
1633
+ route = {}
1634
+
1635
+ # ── (identical to convert_in_and_out) seed routed + run chain_in in background ──
1636
+ forwarded = dict(run_chain_kwargs) if run_chain_kwargs else {}
1637
+ for target in route.values():
1638
+ if isinstance(target, tuple):
1639
+ for arg_name in target[1:]:
1640
+ if arg_name in kwargs:
1641
+ forwarded[arg_name] = kwargs[arg_name]
1642
+
1643
+ routed = dict(modes_state.last_good)
1644
+ routed['root_input'] = kwargs.get("root_input", None)
1645
+ routed.update(forwarded)
1646
+
1647
+ chain_in_error = modes_state.last_error
1648
+ inbound_gen = None
1649
+ if chain_in:
1650
+ # "No value yet" is not a parse job: a host's first external_change edge
1651
+ # fires before its source input has loaded (input None/UNSET), the chain
1652
+ # would just AttributeError on a worker, and the loaded text raises a
1653
+ # real edge moments later anyway (real value load re-flags
1654
+ # external_change). Swallow the empty edge instead of queueing it.
1655
+ if external_change and (input_value is None or input_value is UNSET):
1656
+ external_change = False
1657
+ # Tag this parse with the generation of the source it consumes. If the source is
1658
+ # the echo of our OWN last chain_out (same string object), it reflects that edit's
1659
+ # frame (echo_gen); otherwise it's an external change as of now. Threaded through
1660
+ # the worker snapshot so the result is tagged with the gen actually parsed.
1661
+ src_gen = modes_state.echo_gen if (input_value is modes_state.echo_str) else Melty.frame_count
1662
+ # Identical-snapshot re-arm suppression: a no-op host write can re-fire
1663
+ # the same edge with the SAME string object that's already armed/run -
1664
+ # observed as back-to-back runs on one pending snapshot, the second
1665
+ # re-parsing a byte-identical buffer (~550ms pure re-burn during the
1666
+ # syntax-error hold). Identity only - any real edit is a new source -
1667
+ # and a run_jedi trigger (an Index click) always passes.
1668
+ if (external_change and not forwarded.get("run_jedi")
1669
+ and input_value is getattr(modes_state, "_last_armed_src", None)):
1670
+ external_change = False
1671
+ elif external_change:
1672
+ modes_state._last_armed_src = input_value
1673
+ chain_in_kwargs = {**forwarded, "input_value": input_value,
1674
+ "chain": chain_in, "route": route, "_src_gen": src_gen,
1675
+ "_last_good_routed": modes_state.last_good,
1676
+ "_last_good_src": getattr(modes_state, "last_good_src", None)}
1677
+ # The FIRST parse of a fresh view runs INLINE, size-gated: parsing is
1678
+ # pure-Python, so a worker thread doesn't wall it under the GIL - it
1679
+ # just smears the same CPU across stretched frames, pop-in, and a second
1680
+ # render once the result drops a frame later. Synchronous-first paints
1681
+ # the view fully formed the one sooner. run_in_background inlines only
1682
+ # if its state has no result yet, so every later reparse - typing,
1683
+ # echoes, external changes - stays async/debounced exactly as before.
1684
+ # The size gate prevents a large buffer from freezing its first parse
1685
+ # indefinitely (it falls back to the async path).
1686
+ # background_load=True hidden cache hosts (input-tab feeders) opt OUT of
1687
+ # the synchronous first parse - nobody's looking at them the frame
1688
+ # they load, and a big span's inline parse puts a visible 100ms+ hitch
1689
+ # on whatever the user IS doing (dragging a window). SMALL functions
1690
+ # are carved back in: a function span parse is single-digit ms, and
1691
+ # the context-menu tabs DO look at these hosts the moment they load -
1692
+ # the async hop (worker + debounce + next-frame) was the seconds-long
1693
+ # "sources not loading" feel on every cold menu open.
1694
+ _inline_cap = (_BG_INLINE_FIRST_PARSE_MAX_CHARS if background_load
1695
+ else _INLINE_FIRST_PARSE_MAX_CHARS)
1696
+ inline = (isinstance(input_value, str)
1697
+ and len(input_value) <= _inline_cap)
1698
+ # A direct cst-cache hit is a ~26ms pickle.loads, not a parse - the
1699
+ # async path's frame-hop tax (~90ms+ per span at boot) costs more than
1700
+ # the work. Inline it even for background_load hosts and big spans.
1701
+ # run_jedi excluded: the Index pulse bypasses the cache and must run
1702
+ # a real (expensive) pass on the worker.
1703
+ if not inline and not chain_in_kwargs.get("run_jedi"):
1704
+ inline = chain_parse_cache_has(getattr(input_value, "_disk_span", None),
1705
+ getattr(input_value, "_disk_mtime", None))
1706
+ finished, payload = run_in_background(
1707
+ _run_chain_in,
1708
+ child_kwargs=chain_in_kwargs,
1709
+ name=f"chain_in{unique}", start=external_change, inline_first=inline,
1710
+ debounce_ms=_chain_in_debounce_ms(input_value))
1711
+ if external_change:
1712
+ _ptrace(f"chain_in START edge (unique={unique})", src_gen=src_gen,
1713
+ echo=(input_value is modes_state.echo_str))
1714
+ note = Note(name="convert_in_out, chain in start", tint=(1, 0.5, 0))
1715
+ draw_state._parent.invalidate(note=note)
1716
+ external_change = False
1717
+ if finished and isinstance(payload, dict) and payload.get("safe_skip"):
1718
+ # Newline-only edit: chain skipped host-side (see the safe-
1719
+ # mutation skip in _run_chain_in). Keep the held error / lint /
1720
+ # imports / baseline and clear any lingering error; inbound_gen
1721
+ # stays None - there is no fresh parsed value to order/accept.
1722
+ modes_state.last_error = None
1723
+ chain_in_error = None
1724
+ elif finished and isinstance(payload, dict):
1725
+ # The generation this finished parse reflects (origin edit frame, or "now" for
1726
+ # an external change) - pass to the view_func so its accept/reject ordering
1727
+ # compares against the user's latest LOCAL edit and drops a stale parse.
1728
+ inbound_gen = payload.get("_src_gen")
1729
+ modes_state.last_error = payload.get("error")
1730
+ modes_state.last_lint = payload.get("lint") or []
1731
+ modes_state.last_imports = payload.get("imports") or {}
1732
+ modes_state._lint_deferred = bool(payload.get("lint_deferred"))
1733
+ if payload.get("src_good") is not None:
1734
+ modes_state.last_good_src = payload["src_good"]
1735
+ for name, val in payload["routed"].items():
1736
+ modes_state.last_good[name] = val
1737
+ routed[name] = val
1738
+
1739
+ chain_in_error = modes_state.last_error
1740
+ external_change = True
1741
+ # chain_in just finished on the worker - the fresh parse is now in `routed` /
1742
+ # `last_good`, but the view_func is a SEPARATE cached subtree that
1743
+ # run_in_background's completion never reached: its tile invalidation only
1744
+ # climbs to shared ANCESTORS (Blit->screen.invalidate), not across into the
1745
+ # view_func's descendant tiles. So invalidate our OWN subtree (up) - that
1746
+ # dirties the view_func, so next frame it re-runs with the latest value
1747
+ # instead of replaying a stale blit until some unrelated manual invalidation.
1748
+ note = Note(name="Convert in and out, chain in finished", tint=(1, 0.5, 1.0), draw_state=draw_state)
1749
+ Melty.cache.invalidate_up(draw_state._tile_id, force=True, note=note)
1750
+ notify(f"chain_in finished", tag="chain_in")
1751
+
1752
+ out_changed, out_value = False, input_value
1753
+
1754
+ recompile_error = kwargs.get('error')
1755
+ if isinstance(recompile_error, SyntaxError) and chain_in_error is None:
1756
+ recompile_error = None
1757
+ routed['error'] = recompile_error or chain_in_error
1758
+
1759
+ # ── THE DIFFERENCE: hand the chain_in output (by name) to the view_func ────────
1760
+ # The parsed value is the routed entry the last chain_in node maps to (e.g.
1761
+ # cst_module_to_dict -> "code_dict"). That's what the view_func edits; its return
1762
+ # is the structured edit (vs convert_in_and_out's routed['converted_edit']).
1763
+ primary_key = None
1764
+ if chain_in:
1765
+ tgt = route.get(chain_in[-1])
1766
+ primary_key = tgt[0] if isinstance(tgt, tuple) else tgt
1767
+ primary = routed.get(primary_key) if primary_key is not None else None
1768
+
1769
+ child_kwargs['routed'] = routed
1770
+ edited, edited_value = view_func(input_value=primary, external_change=external_change,
1771
+ inbound_gen=inbound_gen, **child_kwargs)
1772
+ converted_edit = edited_value if (edited and edited_value is not None) else UNSET
1773
+ if edited:
1774
+ draw_state.invalidate(
1775
+ note=Note(name="convert_in_out, view func edit", tint=(1.0, 0.5, 0), draw_state=draw_state))
1776
+
1777
+ # ── (identical) chain_out in background ───────────────────────────────────────
1778
+ if chain_out:
1779
+ co_start = converted_edit is not UNSET
1780
+ indent = _common_indent(input_value) if co_start else ""
1781
+ # The edit that produced converted_edit is this frame's (the view_func reported it
1782
+ # now, same frame bubbling stamped the held value), so its generation is the
1783
+ # current frame. Threaded through the worker snapshot so the output string is
1784
+ # tagged with the edit frame - its chain_in echo is then recognized + ordered.
1785
+ out_gen = Melty.frame_count
1786
+ if co_start:
1787
+ _ptrace(f"chain_out TRIGGERED by view_func edit (unique={unique})",
1788
+ gen=out_gen)
1789
+ co_changed, co_payload = run_in_background(
1790
+ _run_chain_out,
1791
+ child_kwargs={"input_value": converted_edit if co_start else None,
1792
+ "chain": chain_out, "indent": indent, "_out_gen": out_gen},
1793
+ name=f"chain_out{unique}", start=co_start)
1794
+ if co_changed and isinstance(co_payload, dict):
1795
+ if co_payload.get("error") is not None:
1796
+ imgui.text_colored(f" chain_out: {co_payload['error']}", 1.0, 0.5, 0.0)
1797
+ elif "value" in co_payload:
1798
+ out_changed, out_value = True, co_payload["value"]
1799
+ # Remember our own output by ID, + the edit gen it carries, so when it
1800
+ # round-trips back as chain_in's source we recognize the echo and tag the
1801
+ # re-parse with that gen (above) instead of treating it as "new now".
1802
+ modes_state.echo_str = co_payload["value"]
1803
+ modes_state.echo_gen = co_payload.get("_out_gen", out_gen)
1804
+
1805
+ return out_changed, out_value
1806
+
1807
+
1808
+ def code_file_footer(input_value, code_state, **kwargs):
1809
+ if code_state.address is not None:
1810
+ imgui.text(str(code_state.address.path))
1811
+ return False, None
1812
+
1813
+
1814
+ # ── Recompile controls - shared by code_file_io and the context menu's input
1815
+ # tab (draw_input_tab reuses them against a code-host's CodeState, so its Run
1816
+ # button rides the exact same path as a file leaf's). Three pieces because they
1817
+ # render at three different order in code_file_io's body: the button in the top
1818
+ # line, the checkmark beside it, the runner at the end (after this frame's
1819
+ # edits have landed in text_cache).
1820
+
1821
+ def recompile_button(code_state, unique=None, height=30):
1822
+ """The Run (hotswap) button. Hidden until the buffer is loaded. Returns
1823
+ whether it was clicked."""
1824
+ if code_state.text_cache is UNSET or code_state.text_cache is None:
1825
+ return False
1826
+ play_icon = "\uf04b"
1827
+ return RenderFuncs.button(f"{play_icon} Run",
1828
+ tint=(0.05678745, 0.5, 0.2, 0.5),
1829
+ height=height,
1830
+ name=f"recompile_btn{unique}")[0]
1831
+
1832
+
1833
+ def recompile_status(code_state, draw_state):
1834
+ """The fading checkmark after a successful hotswap (same_line, so call it
1835
+ right after the button row)."""
1836
+ if code_state._recompiled_on_frame is None:
1837
+ return
1838
+ duration = 10.0
1839
+
1840
+ recompiled_on = float(Melty.frame_count - code_state._recompiled_on_frame)
1841
+ fade_out = min(1.0, max(0.0, 2.0 - (max(0.0, recompiled_on) / duration)))
1842
+
1843
+ if fade_out >= 0:
1844
+ imgui.same_line()
1845
+ checkmark_icon_fa = "\uf00c"
1846
+ imgui.text_colored(f"{checkmark_icon_fa}", 0.0, 1.0, 0.0, fade_out)
1847
+ if fade_out > 0.01:
1848
+ draw_state.invalidate()
1849
+ request_render()
1850
+ code_state._recompile_on_frame = None
1851
+
1852
+
1853
+ def external_load_status(code_state, draw_state):
1854
+ """Fading "loaded from disk" indication after an external write was picked
1855
+ up and reloaded \u2014 the disk-change counterpart of recompile_status, so an
1856
+ outside program (Claude, git, another editor) saving the file is visible
1857
+ instead of the buffer just silently changing."""
1858
+ if code_state._external_load_frame is None:
1859
+ return
1860
+ duration = 120.0 # linger 2x recompile's checkmark \u2014 easy to miss otherwise
1861
+
1862
+ loaded_for = float(Melty.frame_count - code_state._external_load_frame)
1863
+ fade_out = min(1.0, max(0.0, 2.0 - (max(0.0, loaded_for) / duration)))
1864
+
1865
+ if fade_out >= 0:
1866
+ imgui.same_line(spacing=8)
1867
+ imgui.align_text_to_frame_padding()
1868
+ sync_icon_fa = "\uf021"
1869
+ label = getattr(code_state, "_external_load_label", None) or "loaded from disk"
1870
+ imgui.text_colored(f"{sync_icon_fa} {label} {code_state._external_load_time}",
1871
+ 1.0, 0.75, 0.25, fade_out)
1872
+ if fade_out > 0.01:
1873
+ draw_state.invalidate()
1874
+ request_render()
1875
+ else:
1876
+ code_state._external_load_frame = None
1877
+
1878
+
1879
+ def run_recompile(source, code_state, draw_state, start=False, name="recompile"):
1880
+ """The background hotswap runner (recompile_source via run_in_background —
1881
+ no disk write). Call it unconditionally every frame so the runner can spawn
1882
+ its thread and surface completion; `start` is just the trigger edge. The
1883
+ buffer/address are snapshotted from code_state at trigger time."""
1884
+ changed, result = run_in_background(recompile_source,
1885
+ child_kwargs={"source": source,
1886
+ "code_str": code_state.text_cache,
1887
+ "file_path": code_state.address.path,
1888
+ "address": code_state.address},
1889
+ name=name, start=start)
1890
+ if result == LOADING:
1891
+ print("Starting recompile...")
1892
+ code_state._recompiled_on_frame = None
1893
+ elif result == UNSET:
1894
+ pass
1895
+ else:
1896
+ if changed:
1897
+ print("Recompile successful-------------------------------")
1898
+ code_state._recompiled_on_frame = Melty.frame_count
1899
+ record_compile(code_state.address)
1900
+ Melty.cache.invalidate_up(draw_state._tile_id, max_depth=10)
1901
+ request_render()
1902
+ code_state.recompile_result = result
1903
+
1904
+ def _codec_view(codec, value, caller_view):
1905
+ """Which view renders a codec's loaded `value` inside code_file_io.
1906
+
1907
+ The codec decides the data TYPE; the type decides the view — so a new
1908
+ file type is one codec whose load() returns something with a default
1909
+ renderer, and nothing else has to learn about it. In order:
1910
+
1911
+ 1. A RenderHost capture (render_host_view hands its
1912
+ `_internal_view_func` as view_func): ALWAYS kept. It materializes
1913
+ the value into host["value"] for the host's consumers (the code
1914
+ editor reads the dict) and draws with the host's own renderer.
1915
+ Overriding it with the codec's view skipped materialization — an
1916
+ image host never filled and the editor sat on "Loading…" forever.
1917
+ 2. `codec.view_func` — the explicit override (a type without a default
1918
+ renderer, or a pinned non-default one).
1919
+ 3. A str renders in whatever text view the caller wired (the mode-
1920
+ pinned draw_text_from_code_cache, RenderFuncs.draw_text, …).
1921
+ 4. Anything else routes by type through draw_any (is_default_for)."""
1922
+ from meltygui.core.conversion.render_host import RenderHost
1923
+ if isinstance(getattr(caller_view, "__self__", None), RenderHost):
1924
+ return caller_view
1925
+ if getattr(codec, "view_func", None) is not None:
1926
+ return codec.view_func
1927
+ if isinstance(value, str):
1928
+ return caller_view
1929
+ from meltygui.core.rendering.render_dispatch import draw_any
1930
+ return draw_any
1931
+
1932
+
1933
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
1934
+ # ║ editable_source - the whole round-trip, one function ║
1935
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
1936
+
1937
+ #tstst
1938
+ @render_func(use_cache=True, selectable=False, with_header=draw_header, searchable=False, disable_scroll=True)
1939
+ def code_file_io(input_value, code_state: CodeState, codec=None, view_func=RenderFuncs.draw_text, auto_load=True,
1940
+ auto_load_edits=False, min_height=20, shadow=False, show_add_delete=False, show_bg=False,
1941
+ show_code_buttons=False, show_name=True, is_tree=False,
1942
+ child_kwargs=None, draw_state=None, auto_save=True, auto_recompile_edits=False, save=False, load=False,
1943
+ recompile=False, run_jedi=False, save_debounce_ms=0, bg_offset=-0.5,
1944
+ ensure_import=None, s_key_pressed=None, unique=None,
1945
+ background_load=False, **kwargs):
1946
+ edited = False
1947
+ try:
1948
+ imgui.dummy(0, 0)
1949
+ if child_kwargs is None:
1950
+ child_kwargs = {}
1951
+ # ── 1. Resolve the source's line span ─────────────────────────────────────
1952
+ if codec is None:
1953
+ # Class lookup: match the value's type against registered types,
1954
+ # walking the MRO (like Mode.get_config_for) so a subclass of a
1955
+ # registered type still matches -- e.g. a class object whose
1956
+ # metaclass subclasses `type`, or an extended primitive instance.
1957
+ for klass in type(input_value).__mro__:
1958
+ if klass in type_to_codec:
1959
+ codec = type_to_codec[klass]
1960
+ break
1961
+ # File lookup fallback -- only for a GENUINE path or a bare str.
1962
+ # Hard-type the str check so an extended primitive like
1963
+ # CodeLine(str) (whose type is SOURCE, not a FILE) isn't whacked
1964
+ # into being interpreted as a path. Path uses isinstance so real
1965
+ # path objects (PosixPath, a Path subclass) still match.
1966
+ # codec_for_path = registered codec, else content sniff: text
1967
+ # files edit via TextFileCodec, anything else gets the read-only
1968
+ # binary summary - a real path never lands on "No codec".
1969
+ if codec is None and (isinstance(input_value, Path) or type(input_value) is str):
1970
+ codec = codec_for_path(Path(str(input_value)))
1971
+
1972
+ if codec is None:
1973
+ imgui.text(f"No codec for type: {type(input_value).__name__}")
1974
+ return False, None
1975
+
1976
+ # (Which view renders the loaded value is decided at the call site in
1977
+ # _codec_view - once the value's type is known.)
1978
+
1979
+ # resolve_address runs every frame; min_ms keeps the code-state cache
1980
+ # hits silent while a cold resolve (whole-file getsourcelines tokenize)
1981
+ # shows up on the console.
1982
+ with _pspan("cfio: resolve_address", min_ms=2.0,
1983
+ codec=getattr(codec, '__name__', type(codec).__name__)):
1984
+ address = codec.resolve_address(input_value, draw_state, code_state=code_state)
1985
+ code_state.address = address
1986
+ top_line_height = 30
1987
+ external_change = False
1988
+ imgui.same_line(spacing=0)
1989
+
1990
+ if address is None:
1991
+ # A refused file used to be a blank view; name the reason.
1992
+ from meltygui.code.fileref import writable_file_refusal
1993
+ why = None
1994
+ if isinstance(input_value, (Path, str)):
1995
+ why = writable_file_refusal(input_value) or (
1996
+ None if Path(str(input_value)).is_file() else "not a file")
1997
+ imgui.text_colored(f"Not editable: {input_value}" + (f" — {why}" if why else ""),
1998
+ 0.9, 0.6, 0.5, 0.9)
1999
+ return False, None
2000
+
2001
+ # Run (hotkey) and Index (jedi) only make sense on Python code \u2014 the
2002
+ # codec decides (TypeCodec family: yes; TextFileCodec: .py paths only;
2003
+ # images/binaries: no). `code_buttons` also drives error forwarding and
2004
+ # syntax highlighting below, so the codec verdict stays separate from
2005
+ # `show_code_buttons`, which only gates the button UI (hidden by
2006
+ # default; the per-view for auto-state).
2007
+ code_buttons = codec.show_code_buttons(address)
2008
+
2009
+ if auto_load:
2010
+ if draw_state.frame_count < 1:
2011
+ load = True
2012
+ code_state.text_cache = None
2013
+ code_state.mark_file_current()
2014
+ _ptrace("cfio: initial load trigger",
2015
+ file=address.path.name if address.path else "?",
2016
+ span=(address.start, address.end))
2017
+
2018
+ # str gate on top: even a code codec can briefly hold non-text data.
2019
+ if (code_buttons and show_code_buttons and not auto_recompile_edits
2020
+ and code_state.text_cache is not UNSET
2021
+ and isinstance(code_state.text_cache, str)):
2022
+ recompile = recompile_button(code_state, unique=unique, height=top_line_height)
2023
+
2024
+ if Toggles.enable_jedi and code_buttons and show_code_buttons:
2025
+ imgui.same_line(spacing=0)
2026
+ search_icon = "\uf002"
2027
+ run_jedi = RenderFuncs.button(f"{search_icon} Index",
2028
+ tint=(0.8, 0.54, 0.2),
2029
+ height=top_line_height,
2030
+ name="jedi_index_btn")[0] or run_jedi
2031
+
2032
+ recompile_status(code_state, draw_state)
2033
+ external_load_status(code_state, draw_state)
2034
+
2035
+ file_stale = code_state.is_file_stale()
2036
+ keep_mine = False
2037
+ # A sibling in-process editor of the same file (the cache's str_host
2038
+ # in the structured tab, another tab, a lens save) syncs through
2039
+ # this FILE: its write is not an external change. Reload quietly: no
2040
+ # "loaded from disk" stamp (which also fade-invalidates every update for
2041
+ # seconds). Only a write we did NOT produce gets the indication.
2042
+ self_write = file_stale and FileWatch.is_self_write(address.path)
2043
+ # On a pending local edit, a verified IN-PROCESS write (is_self_write
2044
+ # hash-checks the actual disk content) is absorbed, not conflicted. This
2045
+ # is usually our own write's mtime bump observed before the runner's
2046
+ # completion frame consumed it - the stat above runs BEFORE the save
2047
+ # runner below, so there's always a frame where our write reads back as
2048
+ # stale. Treating it as a conflict blocks `save_start` (below), which
2049
+ # FREEZES the queued save's snapshot while the user keeps editing - the
2050
+ # save then writes an OLD buffer and the file-syncing view displays
2051
+ # the old value. Async must never gate the live buffer or its save on
2052
+ # its own in-flight write. A refused save (_save_refused) is the one
2053
+ # exception: the buffer holds a sibling's write and splice would mangle,
2054
+ # so the conflict must surface. A genuine external program's write
2055
+ # never hash-matches and conflicts as before.
2056
+ if self_write and code_state._pending_save and not code_state._save_refused:
2057
+ code_state.mark_file_current()
2058
+ file_stale = False
2059
+ self_write = False
2060
+ conflict = file_stale and code_state._pending_save
2061
+
2062
+ # No automerge: pending is the current state, and an external write is
2063
+ # the INCOMING side - it is merged in manually (merge window / editor
2064
+ # banner), never silently by this code. A drift file with a live edit
2065
+ # parks on the conflict indicator below until the user resolves it.
2066
+ #
2067
+ # ── Resolved merge: reload from the pending overlay ──────────────
2068
+ # After the user resolved this file's drift (Merge / Keep pending /
2069
+ # Disc save - resolve_external arms the is_absorbed marker), the
2070
+ # pending overlay holds the file's truth: reloading is safe, and
2071
+ # codec.load answers span loads from the pending overlay, so the
2072
+ # MERGED text lands in this buffer - never a disk save; disk is only
2073
+ # written by an explicit save or the shutdown flush. is_absorbed is a
2074
+ # pure held-object identity check (no content compare); a NEW external
2075
+ # write replaces the disk object and naturally re-parks the view.
2076
+ if file_stale and not self_write and not code_state._save_refused:
2077
+ from meltygui.editor.external_changes import ExternalChanges
2078
+ _dpath = str(address.path)
2079
+ _disk_now = Melty.read_code(_dpath)
2080
+ if _disk_now is not None and ExternalChanges.is_absorbed(_dpath, _disk_now):
2081
+ load = True
2082
+ code_state._loaded_externally = True
2083
+ code_state._pending_save = False
2084
+ code_state.mark_file_current()
2085
+ file_stale = False
2086
+ conflict = False
2087
+
2088
+ # ── Cross-view sync via the PendingSave cache (deferred-save model) ────────
2089
+ # A sibling view's save now only goes into PendingSave - no disk write,
2090
+ # so file_stale (mtime) won't fire for it. queue_save wakes our tile; here
2091
+ # we pull that edit from the shared cache if it diverges from our buffer.
2092
+ # Gated on no local edits: the view that PRODUCED the entry matches it (no-op),
2093
+ # and an in-flight edit (_pending_save) must not be clobbered by an older
2094
+ # snapshot. Mirrors the verified self-write in-process sync below.
2095
+ if (not file_stale and auto_load_edits and not code_state._pending_save
2096
+ and not code_state._save_refused and isinstance(code_state.text_cache, str)):
2097
+ cache_text = PendingSave.pending_text_for(address)
2098
+ if cache_text is not None and cache_text != code_state.text_cache:
2099
+ code_state.text_cache = cache_text
2100
+ code_state.mark_file_current()
2101
+ external_change = True
2102
+ # Ancestors-only: external_change flows into the view_func's
2103
+ # draw= bypass this same body run, so the subtree that renders
2104
+ # from the hosts updates through its own data flow. The old
2105
+ # depth-6 force-sweep here rebuilt the structured pane for
2106
+ # EVERY keystroke (each body runs per key - the editor's
2107
+ # str_host queues into PendingSave every edit).
2108
+ draw_state.invalidate(note=Note(name="pending-sync", tint=(1, 0.6, 0.2)))
2109
+ request_render()
2110
+
2111
+ # A read-only codec (images, binaries) has nothing local to lose: an
2112
+ # external write just reloads. no self-write check, no "changed on
2113
+ # disk" stamp, no conflict (there can be no pending edit).
2114
+ if file_stale and not codec.editable:
2115
+ load = True
2116
+ code_state._loaded_externally = not self_write
2117
+ code_state._pending_save = False
2118
+ code_state.mark_file_current()
2119
+ file_stale = False
2120
+ conflict = False
2121
+
2122
+ if file_stale and not code_state._pending_save:
2123
+ if auto_load_edits and self_write:
2124
+ # A VERIFIED self-write (a sibling editor of the same file - the
2125
+ # code-host str_host, another window, a lens save - synced
2126
+ # through disk) is picked up IN-PROCESS from the exact text that
2127
+ # write produced: no disk reload, no "Loading..." banner, no
2128
+ # external stamp. A reparse still fires (text_cache change +
2129
+ # external_change below), so the structured pane updates exactly
2130
+ # as the disk-load path drove it. get_self_write_text returns
2131
+ # None if an external write has disk raced in - fall through to
2132
+ # the manual branch below rather than auto-loading stale text.
2133
+ mem_text = FileWatch.get_self_write_text(address.path)
2134
+ if mem_text is not None:
2135
+ code_state.text_cache = codec.load(address, source_text=mem_text)
2136
+ code_state.mark_file_current()
2137
+ code_state._save_refused = False
2138
+ external_change = True
2139
+ # Ancestors-only, same reasoning as the pending-sync above:
2140
+ # this self-write sync also lands on per keystroke save,
2141
+ # and the depth-6 force sweep rebuilt the structured pane
2142
+ # each time. external_change -> draw= re-runs the view.
2143
+ draw_state.invalidate(note=Note(name="self-write mem-sync", tint=(1, 0.6, 0.2)))
2144
+ request_render()
2145
+ else:
2146
+ load = True
2147
+ code_state._loaded_externally = False
2148
+ code_state.mark_file_current()
2149
+ else:
2150
+ # A genuine external change is NEVER auto-loaded - the write is
2151
+ # the incoming side of a manual merge (merge window / editor
2152
+ # banner). Indicate and offer the merge window, and keep the
2153
+ # explicit per-span buttons as escape hatches.
2154
+ imgui.same_line(spacing=8)
2155
+ imgui.align_text_to_frame_padding()
2156
+ imgui.text_colored("\uf071 changed on disk", 1.0, 0.55, 0.15, 1.0)
2157
+ imgui.same_line(spacing=4)
2158
+ if get_service("conflicts_open") and RenderFuncs.button("Merge…", width=100, height=top_line_height,
2159
+ name=f"openmerge{unique}")[0]:
2160
+ from meltygui.core.runtime.extensions import call
2161
+ call('conflicts_open', address.path)
2162
+ imgui.same_line()
2163
+ if RenderFuncs.button("Load", width=100, height=top_line_height, name=f"reload{unique}")[0]:
2164
+ load = True
2165
+ code_state._loaded_externally = not self_write
2166
+ imgui.same_line()
2167
+ if RenderFuncs.button("Keep mine", width=100, height=top_line_height, name=f"keepmine{unique}")[0]:
2168
+ save = True
2169
+ keep_mine = True
2170
+ elif conflict:
2171
+ # External write + local unsaved edits: a write conflict. Auto-save
2172
+ # is blocked below until the user picks a side - splicing a buffer
2173
+ # that came from the OLD file into the rewritten one is exactly the
2174
+ # file-mangling bug. The merge window is the primary resolution;
2175
+ # Keep mine writes with force (skipping the codec's span-fingerprint
2176
+ # guard) through the freshly re-resolved span.
2177
+ imgui.same_line(spacing=8)
2178
+ imgui.align_text_to_frame_padding()
2179
+ imgui.text_colored("\uf071 changed on disk", 1.0, 0.55, 0.15, 1.0)
2180
+ imgui.same_line(spacing=4)
2181
+ if get_service("conflicts_open") and RenderFuncs.button("Merge…", width=100, height=top_line_height,
2182
+ name=f"openmerge{unique}")[0]:
2183
+ from meltygui.core.runtime.extensions import call
2184
+ call('conflicts_open', address.path)
2185
+ imgui.same_line()
2186
+ if RenderFuncs.button("Load theirs", width=110, height=top_line_height, name=f"reload{unique}")[0]:
2187
+ load = True
2188
+ code_state._loaded_externally = True
2189
+ code_state._pending_save = False
2190
+
2191
+ # Without this the pending save keeps answering the load
2192
+ # with the edit being discarded (codec.load prefers it).
2193
+ PendingSave.discard_entry_for(address)
2194
+ imgui.same_line()
2195
+ if RenderFuncs.button("Keep mine", width=100, height=top_line_height, name=f"keepmine{unique}")[0]:
2196
+ save = True
2197
+ keep_mine = True
2198
+ # The queued entry sits in pre-external-write coordinates; the
2199
+ # forced save below re-queues the buffer under the freshly
2200
+ # resolved address, so drop the stale-span twin.
2201
+ PendingSave.discard_entry_for(address)
2202
+
2203
+ if not auto_save and code_state._pending_save:
2204
+ imgui.same_line(spacing=0)
2205
+ if RenderFuncs.button("Save", width=100, height=top_line_height, name=f"save{unique}")[0]:
2206
+ save = True
2207
+
2208
+ # if auto_save:
2209
+ # imgui.same_line(spacing=16)
2210
+ # imgui.align_text_to_frame_padding()
2211
+ # imgui.text_colored(str(f" saving"), (1.0, 1.0, 1.0, 0.2))
2212
+
2213
+ # Hidden cache hosts (background_load) never load inline - the disk
2214
+ # read + span resolve can cost ~100ms and nobody sees their first frame.
2215
+ changed, new_text = run_in_background(load_file, main_thread=True,
2216
+ child_kwargs={"input_value": address, 'codec': codec},
2217
+ name=f"load{unique}", start=load,
2218
+ inline_first=not background_load)
2219
+ if new_text is LOADING:
2220
+ code_state.mark_file_current()
2221
+
2222
+ elif changed:
2223
+ _ptrace("cfio: load landed",
2224
+ file=address.path.name if address.path else "?",
2225
+ chars=len(new_text) if isinstance(new_text, str) else -1)
2226
+ code_state.text_cache = new_text
2227
+ code_state.mark_file_current()
2228
+ draw_state.invalidate_up(max_depth=6)
2229
+ code_state._pending_save = False
2230
+ code_state._save_refused = False
2231
+ external_change = True
2232
+
2233
+ if code_state._loaded_externally:
2234
+ # This load was triggered by a disk change (not the initial
2235
+ # load) - stamp the fading "loaded from disk" label.
2236
+ code_state._loaded_externally = False
2237
+ code_state._external_load_frame = Melty.frame_count
2238
+ code_state._external_load_time = datetime.now().strftime("%H:%M:%S")
2239
+ code_state._external_load_label = None
2240
+ request_render()
2241
+
2242
+ # ── 3. Edit - the actual call ─────────────────────────────────────────────
2243
+ if code_state.text_cache is UNSET or code_state.text_cache is None:
2244
+ # Buffer still loading: this frame renders nothing where the editor
2245
+ # will be - keep ancestors' persisted content_height (see
2246
+ # Melty.pending_placeholder_frame).
2247
+ Melty.pending_placeholder_frame = Melty.frame_count
2248
+ if code_state.text_cache is not UNSET and code_state.text_cache is not None:
2249
+
2250
+ child_kwargs['jump_to'] = address
2251
+ # The Index button's click rides through to the chain: code_module_to_gp
2252
+ # runs jedi and attaches the index straight to the gp it builds.
2253
+ child_kwargs['run_jedi'] = run_jedi
2254
+ # Error signals reach the editor's red-line highlight by this route -
2255
+ # code_text_io does NO parse of its own:
2256
+ # • SYNTAX errors: chain_in parses the buffer on its background thread
2257
+ # and routes the result to draw_text (`code_tree` on success, the
2258
+ # parse exception on failure). Clears the instant a fresh parse OKs.
2259
+ # - RECOMPILE errors: the hotswap can fail on a SyntaxError (Python's
2260
+ # compiler pins a better line than libcst). Route it down as `error`.
2261
+ # - RUNTIME errors: a hotswap that compiled clean can throw when its
2262
+ # new code RUNS during a later render - the hotswap guard catches the
2263
+ # live object back and records the error here. It carries an
2264
+ # editor-relative `editor_line`, so it highlights the offending line.
2265
+ _runtime_error = hotswap_guard.get_runtime_error(input_value)
2266
+ _recompile_error = (code_state.recompile_result
2267
+ if isinstance(code_state.recompile_result, BaseException)
2268
+ else None)
2269
+ # Error checking rides the same codec switch as Run/Index: a None
2270
+ # root_input makes draw_text_from_code_cache skip the code-host
2271
+ # cache entirely - no Python parse of a .txt buffer, no syntax-error
2272
+ # highlight on plain text, and no background reparse per keystroke.
2273
+ child_kwargs['error'] = (_runtime_error or _recompile_error) if code_buttons else None
2274
+ child_kwargs['root_input'] = input_value if code_buttons else None
2275
+ # Same switch again: a .txt buffer gets plain 'default'-colored
2276
+ # text instead of Python-tokenized Darcula colors (draw_text builds
2277
+ # inline token widgets with it).
2278
+ child_kwargs['syntax_highlight'] = code_buttons
2279
+ # Enclosing-cell column edges ride down like jump_to: code_file_io
2280
+ # is transparent to the column system (its box IS the cell
2281
+ # content), so a host row passes its cell's edge dicts BY
2282
+ # REFERENCE and the nested view's Column host adopts them as its
2283
+ # own edges (draw_function_live's source column). Stamped
2284
+ # unconditionally - usually None - so a shared child_kwargs dict
2285
+ # never carries one window's edges into another.
2286
+ child_kwargs['left_edge'] = kwargs.get('left_edge')
2287
+ child_kwargs['right_edge'] = kwargs.get('right_edge')
2288
+ # The view's reconvert trigger: load / external edit (external_change),
2289
+ # the Index button (run_jedi), and a buffer edit from last frame
2290
+ # (_reconvert) - chain_in re-parses typed text and surfaces syntax
2291
+ # errors. The view passes it as-is to chain_in, but the whole trigger
2292
+ # lives here, not in the view.
2293
+ #
2294
+ # Pass it as BOTH external_change (the body reads it for `start=`) and
2295
+ # draw=True : the view is blit-cached, so without bypassing that cache its
2296
+ # body is skipped and the trigger never fires. draw=True is the view's
2297
+ # one-shot cache bypass (no sticky edit flags), so the body runs at
2298
+ # frame whenever we asked for a reconvert.
2299
+ reconvert = code_state._reconvert
2300
+ code_state._reconvert = False
2301
+ trigger = external_change or run_jedi or reconvert
2302
+ # When this code_file_io is a NON-scrolling pane (the func/class
2303
+ # context code tabs pass disable_scroll=True), the inner editor must
2304
+ # be the SOLE scroll container - so pin it to the visible clip. Its
2305
+ # height then matches the viewport: it overflows and scrolls for a
2306
+ # source of ANY size (not only one past the 30000px height clamp),
2307
+ # and the wrapper's scrollbar (clip_height = draw_state.height) draws
2308
+ # the right grab ratio. Without this the editor grows to its content,
2309
+ # "fits" itself (no scroll) yet is clipped to the clip - its bottom
2310
+ # cuts off and unreachable. Mirrors draw_code_tabs_from_cache's pane
2311
+ # pinning. Other code_file_io users (standalone editor windows,
2312
+ # folder leaves, offscreen code-host str_hosts) are NOT disable_scroll
2313
+ # and keep growing to content with the window/wrapper owning scroll.
2314
+ # if (kwargs.get("disable_scroll") and draw_state.abs_clip_rect is not None
2315
+ # and "height" not in child_kwargs):
2316
+ # cursor_top = imgui.get_cursor_screen_pos()[1]
2317
+ # child_kwargs["height"] = max(50.0, draw_state.abs_clip_rect[3] - cursor_top)
2318
+ # # A passed height makes the editor fixed_size (auto_resize off),
2319
+ # # so the wrapper no longer expands its width to the available
2320
+ # # content area - it'd collapse to min_width. Pin the width to
2321
+ # # the code_file_io's available width too, exactly as the NEW_CODE
2322
+ # # columns path passes width alongside height.
2323
+ # child_kwargs.setdefault("width", draw_state.content_width)
2324
+ view = _codec_view(codec, code_state.text_cache, view_func)
2325
+ edited, value = view(input_value=code_state.text_cache,
2326
+ external_change=trigger, draw=trigger,
2327
+ **child_kwargs)
2328
+
2329
+ # A read-only codec's view can surface a change (a gesture, a
2330
+ # host echo) but nothing flows back to the file - the loaded value
2331
+ # stays authoritative and no save is ever armed.
2332
+ if edited and not codec.editable:
2333
+ edited = False
2334
+ if edited:
2335
+ code_state.text_cache = value
2336
+ code_state.mark_file_current()
2337
+ code_state._pending_save = True
2338
+ code_state._reconvert = True
2339
+ else:
2340
+ edited = False
2341
+
2342
+ # ── 4. Save / recompile - on background threads ───────────────────────────
2343
+ # Both route through run_in_background, the same one-shot runner load uses.
2344
+ # Each call site has a different name=, so each gets its OWN injected
2345
+ # loading_state - save and recompile can't clobber each other (or load).
2346
+ # We call them unconditionally every frame so the runner can spawn the
2347
+ # thread and surface completion; `start=` is just the trigger edge.
2348
+ save_hotkey = bool(s_key_pressed and s_key_pressed.ctrl)
2349
+
2350
+ # Save: write the edited span back to disk off the main thread. The text is
2351
+ # snapshotted into child_kwargs at trigger time, so a later edit can't race
2352
+ # the in-flight write. Auto-save-on-edit is debounced so a burst of
2353
+ # keystrokes collapses into one write after typing pauses; the explicit
2354
+ # save button / Ctrl+S fires immediately (debounce 0).
2355
+ #
2356
+ # INVARIANT - the save is a write-only side channel off the live buffer:
2357
+ # it must never hold the UI back. The buffer (text_cache) and everything
2358
+ # rendered from it always run ahead; the save trails behind. Two rules
2359
+ # keep that true:
2360
+ # 1. The queued snapshot must hold the LATEST buffer until launch:
2361
+ # run_in_background's one-slot queue is refreshed by every `start`,
2362
+ # so every edit while a save is queued/in flight MUST re-arm
2363
+ # save_start. Any condition that gates save_start across multiple
2364
+ # edit frames (today: `not conflict`) must be impossible to trigger
2365
+ # from the save's own lifecycle - otherwise the queue freezes on an
2366
+ # old buffer and the completed write pushes the OLD version out to
2367
+ # every view that syncs through the file.
2368
+ # 2. Our own write must never read back as an external change. The
2369
+ # mtime bump is absorbed on busy frames (result is LOADING - which
2370
+ # includes completed-but-superseded runs), on the reported `saved`
2371
+ # frame, and by the verified self-write absorb above for any frame
2372
+ # that runs between them.
2373
+ explicit_save = codec.editable and (save_hotkey or save)
2374
+ # During a conflict (external write + pending local edit) the debounced
2375
+ # auto-save is OFF - only an explicit save (Keep mine / Save / Ctrl+S)
2376
+ # writes, and it writes with force past the codec's span guard. An
2377
+ # already-in-flight debounced save is caught by that guard instead and
2378
+ # comes back as SaveConflict (handled below).
2379
+ save_start = (auto_save and edited and not conflict) or explicit_save
2380
+ force_save = keep_mine or (conflict and explicit_save)
2381
+ save_debounce = 0 if explicit_save else save_debounce_ms
2382
+ if save_start:
2383
+ note = Note(name="Code_file_io save start", tint=(1, 0.5, 0))
2384
+ # Melty.cache.invalidate_up(draw_state._parent._tile_id, max_depth=4, note=note)
2385
+
2386
+ saved, result = run_in_background(save_file, main_thread=True,
2387
+ child_kwargs={"address": address,
2388
+ "codec": codec,
2389
+ "code_str": code_state.text_cache,
2390
+ "ensure_import": ensure_import,
2391
+ "parent_ds": draw_state,
2392
+ "force": force_save},
2393
+ name=f"save{draw_state.name}", start=save_start,
2394
+ debounce_ms=save_debounce)
2395
+ if result is LOADING:
2396
+ code_state.mark_file_current()
2397
+
2398
+ elif saved and isinstance(result, SaveConflict):
2399
+ # The codec refused the splice - the on-disk span changed under the
2400
+ # in-flight write. Nothing was written: keep the edit pending but
2401
+ # mark the file stale so the conflict UI above surfaces next frame.
2402
+ # _save_refused disables the self-write absorb (the disk likely
2403
+ # holds a SIBLING editor's in-process write, which would otherwise
2404
+ # hash-match and silently re-absorb the conflict on this frame).
2405
+ code_state._save_refused = True
2406
+ code_state.mark_file_stale()
2407
+ request_render()
2408
+
2409
+ elif saved:
2410
+ # Our own write bumped mtime; clear the stale flag set on edit so the
2411
+ # next frame doesn't read the disk as an external change.
2412
+ code_state.mark_file_current()
2413
+ code_state._pending_save = False
2414
+ code_state._save_refused = False
2415
+ note = Note(name="On saved, code_file_io", tint=(0.5, 1.0, 1.0), draw_state=draw_state)
2416
+ Melty.cache.invalidate_up(draw_state._parent._tile_id, max_depth=4, note=note)
2417
+
2418
+ # Recompile (hotswap, no disk write): the Run button. Ctrl+Enter is
2419
+ # the GLOBAL recompile-all now (draw_main's root host → PendingSave).
2420
+ # Same runner, its own loading_state. Deliberately NO edit-driven auto
2421
+ # trigger here: these hosts also back VISIBLE editor panes, so any
2422
+ # `edited`-keyed condition fires per keystroke (tried and reverted —
2423
+ # even origin-tagged edits misfire, since a synced-in keystroke
2424
+ # _materializes and reads as a value write). Programmatic writers
2425
+ # (set_anywhere) drive run_recompile themselves, writer-side.
2426
+ recompile_start = recompile
2427
+ run_recompile(input_value, code_state, draw_state, start=recompile_start)
2428
+
2429
+ except Exception as e:
2430
+ imgui.text_colored(f"editable_source error: {e}", 1.0, 0.4, 0.0)
2431
+
2432
+ if not hasattr(code_state, "_resolve_stack"):
2433
+ def get_frames(an_e):
2434
+ """Extract live frames from a caught exception's traceback."""
2435
+ tb = an_e.__traceback__
2436
+ if tb is None:
2437
+ return []
2438
+ results = []
2439
+ max_depth = 10
2440
+ while tb is not None or len(results) >= max_depth:
2441
+ frame_obj = tb.tb_frame
2442
+ results.append(frame_obj)
2443
+ tb = tb.tb_next
2444
+
2445
+ return results
2446
+
2447
+ frames = get_frames(e)
2448
+ setattr(code_state, "_resolve_stack", frames)
2449
+
2450
+ from meltygui.core.rendering.render_dispatch import draw_any
2451
+ call_stack = getattr(code_state, "_resolve_stack", [])
2452
+ RenderFuncs.draw_collection(call_stack, name=f"resolve_stack{unique}{id(call_stack)}",
2453
+ mode=Modes.WINDOW, tint=(0.9, 0.4, 0.1))
2454
+ print_stack_trace(exception=e)
2455
+
2456
+ # This is the root node, end of the line: code_file_io will load/edit/save
2457
+ # itself, so it returns the ORIGINAL input, never the edited text. When a
2458
+ # collection applies a changed child value back into its hosts; text
2459
+ # here would replace the held Path/class with a string. Consumers that want
2460
+ # the edited value (RenderHost) inject themselves as view_func and receive
2461
+ # it through that channel instead.
2462
+ return edited, input_value
2463
+
2464
+
2465
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
2466
+ # ║ CodeHost cache - shared source/cst hosts per function/class/module ║
2467
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
2468
+
2469
+ # (source_ref | CallSite) -> (str_host, dict_host). Process-wide; entries are
2470
+ # created lazily on first request and live for the session. Hotswap keeps
2471
+ # function/class/module identities stable (the original wrapper/raw stay
2472
+ # canonical), so reference keys survive recompiles; CallSite is a frozen
2473
+ # value-equality key. Each host watches its own file (auto_load_edits), so a
2474
+ # cached entry stays current when the file changes on disk.
2475
+ _code_host_cache: dict = {}
2476
+
2477
+
2478
+ def code_hosts_for(ref):
2479
+ """The shared (source_str_host, cst_dict_host) RenderHost pair for a
2480
+ function / class / module / CallSite — the same wiring draw_input_tab used
2481
+ to build per menu-open, now built ONCE per distinct reference and reused:
2482
+
2483
+ str_host code_file_io <- ref (the editable source text)
2484
+ dict_host convert_in_and_out_value (source <-> cst dict via the
2485
+ <- str_host NEW_CODE chain, "code_dict"
2486
+ routed to the editor)
2487
+
2488
+ Lazy: nothing loads until the first consumer draws the host. Consumers that
2489
+ read the value outside the host's own draw loop must still register via
2490
+ host.notify_on_change(draw_state), exactly as before."""
2491
+ # Key by the ref ITSELF, not int(id(ref)). Functions/classes/modules hash by
2492
+ # identity - stable for the session, one entry each (both before and now).
2493
+ # But CallSite/Decorations are frozen dataclasses and Path is a new type: a
2494
+ # FRESH object is built per request (draw_input_tab does `CallSite(f, ln)` on
2495
+ # every menu-open), so an identity key MISSED every single time: a new host
2496
+ # pair created, registered in Melty.render_hosts, and never removed. Worse, the
2497
+ # transient key was then GC'd and its address recycled, so later id()s collided
2498
+ # and silently overwrote (or mis-returned) cache entries. Value-equality keying
2499
+ # collapses all those to one entry per distinct source.
2500
+ # Functions/classes key by (module, qualname), not object identity: a
2501
+ # whole-file hotswap re-exec can hand callers a NEW wrapper object for
2502
+ # the same def (draw_state._view_func picks up whichever object the
2503
+ # registries can resolve, and the src./bare twin mirroring makes
2504
+ # that alternate). Identity keying then missed on every recompile and
2505
+ # LEAKED a fresh host pair per swap (the draw_dropdown recreation leak) -
2506
+ # each pair registered in Melty.render_hosts and never collapsed. The
2507
+ # module name is normalized across the src./bare dual identity so both
2508
+ # spellings of the same file share one entry.
2509
+ key = ref
2510
+ if isinstance(ref, (types.FunctionType, type)):
2511
+ mod = getattr(ref, "__module__", "") or ""
2512
+ qualname = getattr(ref, "__qualname__", None)
2513
+ if qualname:
2514
+ if mod.startswith("src."):
2515
+ mod = mod[4:]
2516
+ key = ("code_host", mod, qualname)
2517
+ try:
2518
+ pair = _code_host_cache.get(key)
2519
+ cacheable = True
2520
+ except TypeError: # genuinely unhashable ref - skip the cache
2521
+ pair, cacheable = None, False
2522
+ if pair is not None and key is not ref and pair[0].input_value is not ref:
2523
+ # Same logical def, different object (post-hotswap identity churn): point
2524
+ # the str_host at the caller's live ref so span editing see the
2525
+ # patched object instead of a stale pre-swap wrapper.
2526
+ pair[0].input_value = ref
2527
+ if pair is None:
2528
+ from meltygui.core.conversion.render_host import RenderHost
2529
+ label = getattr(ref, "__name__", None) or type(ref).__name__
2530
+ # Unique disambiguator: resolve_host_view resolves a host BY NAME, so two live
2531
+ # hosts must not collide. Every cached ref is held alive as a dict key, so
2532
+ # their id()s are all distinct - unique per concurrent host, and stable for
2533
+ # the entry's life (the value-key keeps THIS ref's id from being recycled).
2534
+ tag = id(ref)
2535
+ str_host = RenderHost(io_function=code_file_io, input_value=ref, evictable=True,
2536
+ name=f"##code_cache_{label}{tag}_str",
2537
+ child_kwargs={"auto_load_edits": True, "auto_save": True,
2538
+ "background_load": True})
2539
+
2540
+ # str_proxy = RenderHost(io_function=code_file_io, input_value=draw_text, name="String Proxy test",
2541
+ #
2542
+ # child_kwargs={"auto_load_edits": False, "auto_load":True}) # auto-reload on external file change
2543
+ #
2544
+
2545
+ # Whole-FILE refs get the full static name/signature lint (code_dict):
2546
+ # the buffer is self-contained, so an unresolved name really is a
2547
+ # NameError. A span ref (function/class/CallSite) sees none of its
2548
+ # module's imports, so it gets the SPAN pass instead (lint_span): with
2549
+ # the defining module's file as lint base, the module's current text
2550
+ # binds suppress every name the module actually binds, so names an
2551
+ # import would bind are reported - but only when that module is live in
2552
+ # sys.modules (otherwise nothing suppresses, so no lint at all) - and
2553
+ # call signatures check against the module file's PENDING text
2554
+ # (code_checks._check_call_span), so a signature edit in another view
2555
+ # flags wrong call sites before any recompile.
2556
+ lint_path, lint_span = None, False
2557
+ if isinstance(ref, Path) and ref.suffix == ".py":
2558
+ lint_path = str(ref)
2559
+ elif isinstance(ref, types.ModuleType):
2560
+ lint_path = getattr(ref, "__file__", None)
2561
+ elif isinstance(ref, (types.FunctionType, type)):
2562
+ # Only real def/class refs: an INSTANCE ref (CallSite, ...) reports
2563
+ # its CLASS's defining module via __module__, not the edited file -
2564
+ # linting against that namespace would be wrong, so those stay
2565
+ # lint-free as before.
2566
+ ref_mod = sys.modules.get(getattr(ref, "__module__", None) or "")
2567
+ lint_path = getattr(ref_mod, "__file__", None)
2568
+ lint_span = lint_path is not None
2569
+ dict_host = RenderHost(
2570
+ io_function=convert_in_and_out_value, input_value=str_host, evictable=True,
2571
+ name=f"##code_cache_{label}{tag}_dict",
2572
+ child_kwargs={
2573
+ "background_load": True,
2574
+ "chain_in": [string_to_cst_module, cst_module_to_dict],
2575
+ "chain_out": [dict_to_cst_module, cst_module_to_string],
2576
+ "route": {cst_module_to_dict: ("code_dict", "jump_to", "run_jedi", "drive")},
2577
+ **({"run_chain_kwargs": {"lint_path": lint_path,
2578
+ "lint_span": lint_span}} if lint_path else {}),
2579
+ })
2580
+ # The visible editor requests its first lint on the input-quiet worker.
2581
+ # A future timestamp prevents a scheduled lint stranded idle files.
2582
+ dict_host._last_relint_t = 0.0
2583
+ dict_host._relint_pending = bool(lint_path)
2584
+ pair = (str_host, dict_host)
2585
+ if cacheable:
2586
+ _code_host_cache[key] = pair
2587
+ _ptrace(f"host pair created for {label}", cached=cacheable,
2588
+ total_hosts=len(_code_host_cache))
2589
+ return pair
2590
+
2591
+
2592
+ def host_code_state(host):
2593
+ """The CodeState living inside a code str_host's wrapper — code_file_io's
2594
+ injected state, holding the LIVE buffer (text_cache) and resolved address.
2595
+ Same lookup shape as draw_text_from_code_cache's ModesState scan: injected
2596
+ states sit in the wrapper draw_state's misc, keyed by param name. None until
2597
+ the host has drawn at least once (hosts are lazy)."""
2598
+ wds = getattr(host, "_wrapper_draw_state", None)
2599
+ for v in (getattr(wds, "misc", None) or {}).values():
2600
+ if isinstance(v, CodeState):
2601
+ return v
2602
+ return None
2603
+
2604
+
2605
+ # Monotonic time of the last auto-index nudge. One host per window: at
2606
+ # session start every open editor's parse predates the warmer's first build,
2607
+ # and letting them all index at once stacks several ~0.2s GIL-holding passes
2608
+ # against the render thread. Time-based, NOT frame-based - the render loop is
2609
+ # event-driven, so N frames at idle can be unbounded wall-clock time. Skipped
2610
+ # hosts simply retry on a later frame.
2611
+ _last_auto_index_time = 0.0
2612
+ _AUTO_INDEX_STAGGER_S = 0.25
2613
+
2614
+
2615
+ def _host_label(host):
2616
+ """Short perf-trace label for a code host ("Toggles140234_dict")."""
2617
+ return str(getattr(host, "name", "?")).replace("##code_cache_", "")
2618
+
2619
+
2620
+ def _post_symbol_attach(dict_host, gen, flat):
2621
+ """Attach a computed {symbol: SymbolUsage} map onto the host's held gp at the
2622
+ next frame boundary (Melty.post_to_render). Deferred-not-inline because the gp
2623
+ is walked live every frame and inserting __symbol_usages__ keys mid-iteration
2624
+ raises 'dictionary changed size during iteration' (see _index_host_in_place).
2625
+ Shared by the inline fast path and the background recompute path."""
2626
+ from meltygui.code.libcst_conversion import _distribute_by_name
2627
+
2628
+ def _attach():
2629
+ gp = dict_host._held()
2630
+ if not isinstance(gp, dict):
2631
+ # The computed symbols had nowhere to land (host holds no parse yet)
2632
+ # - the work is wasted and must be re-triggered later.
2633
+ _ptrace("attach: DROPPED — host holds no parse yet",
2634
+ host=_host_label(dict_host))
2635
+ return
2636
+ with _pspan("attach: distribute on render thread", min_ms=2.0,
2637
+ host=_host_label(dict_host), names=len(flat) if flat else 0):
2638
+ # Same-names attach (per-keystroke offset remap: cursor moved,
2639
+ # symbol text unchanged) skips the consumer sweep - the editor reads
2640
+ # the_usage LIVE each draw (us_call in draw_text perf), so it
2641
+ # tracks positions without a repaint order, and the depth-8 force
2642
+ # draw was rebuilding the structured pane on each keystroke. A
2643
+ # changed name set (new/removed symbol) still notifies so usage
2644
+ # boxes/links appear and disappear promptly.
2645
+ prev = getattr(gp, "symbol_usage", None)
2646
+ same_names = (isinstance(prev, dict) and isinstance(flat, dict)
2647
+ and prev.keys() == flat.keys())
2648
+ # Stamp the generation only when the compute actually landed on
2649
+ # the live buffer. A hold (typing quiet-gate / inflight dedup)
2650
+ # leaves the stale index uncached - stamping THAT as current-gen
2651
+ # blocked the ensure pass's retry forever, so a symbol typed
2652
+ # mid-hold (a newly imported name) never got indexed or tinted.
2653
+ # On a stale attach, clear the nudge key so the per-frame ensure
2654
+ # pass respawns (stagger-throttled) until a real gen fires.
2655
+ from meltygui.code.libcst_conversion import usages_fresh_for_address
2656
+ if usages_fresh_for_address(dict_host.child_kwargs.get("jump_to")):
2657
+ gp._symbol_gen = gen
2658
+ else:
2659
+ gp._symbol_gen = None
2660
+ dict_host._auto_index_key = None
2661
+ if flat:
2662
+ gp.symbol_usage = flat
2663
+ _distribute_by_name(gp, flat)
2664
+ if not same_names:
2665
+ dict_host._notify_consumers(name="symbol index attached")
2666
+ else:
2667
+ _ptrace("attach: names unchanged — consumer sweep skipped",
2668
+ host=_host_label(dict_host))
2669
+
2670
+ Melty.post_to_render(_attach)
2671
+
2672
+
2673
+ def _ensure_symbol_index(dict_host, str_host, code_dict, jump_to=None):
2674
+ """Auto-index trigger: re-run the cache's chain_in when the held parse
2675
+ predates the current symbol index — either it never got a symbol pass
2676
+ (fresh session: the host's first parse ran before any editor stamped
2677
+ jump_to into its child_kwargs) or the background cache warmer
2678
+ (SymbolIndexCache) advanced a generation because src files changed, so
2679
+ cross-file callers may have moved. The chain itself attaches the symbols
2680
+ (cst_module_to_dict's auto pass); this only supplies `jump_to` and the
2681
+ re-run edge. One nudge per (parse identity, generation), so a span whose
2682
+ index is legitimately empty doesn't re-trigger every frame.
2683
+
2684
+ The trigger keys on the file's PENDING-edit generation, not just the parse
2685
+ identity + index generation: a blank-line edit bumps pending_gen WITHOUT a new
2686
+ parse or an index-gen bump, and the symbol POSITIONS must follow it. The old
2687
+ `_symbol_gen == gen` gate (index gen only) froze the symbols at the last
2688
+ REPARSE — so a newline's offset waited for the ~0.5s cst→dict reparse. Now the
2689
+ inline offset fires per pending edit and tracks the live buffer.
2690
+
2691
+ Tiers: a position-only edit (blank-line shift) is handled INLINE here and
2692
+ attaches next frame — no cooperative yield, no stagger, no background hop.
2693
+ The probe itself stays cheap per keystroke because the O(names) remap is
2694
+ debounced inside _compute_symbol_usages (_SHIFT_MAT_MIN_S): mid-burst it
2695
+ serves the held base (an identity no-op attach) and materializes the
2696
+ composite shift a few times a second. A `_NEEDS_RECOMPUTE` (within-line / substantial edit) that the
2697
+ chain's reparse already covers (parse indexed at the current index gen) is left
2698
+ to that reparse — we do NOT spawn a recompute per keystroke. Only a genuinely
2699
+ gen-stale parse (fresh parse / cross-file warmer bump) takes the deferred
2700
+ background recompute behind the yield + stagger."""
2701
+ global _last_auto_index_time
2702
+ if dict_host is None or not isinstance(code_dict, dict):
2703
+ return
2704
+ if not (Toggles.enable_jedi
2705
+ and Toggles.TextEditor.SymbolUsages.auto_index
2706
+ and not Toggles.jedi_correctness):
2707
+ return
2708
+ import meltygui.code.libcst_conversion as _lc
2709
+ gen = _lc._index_generation
2710
+ # An incremental merge carried the previous flat symbol map but could not
2711
+ # redistribute the leaf-node __symbol_usages__ (shared-dict mutation from
2712
+ # the worker - see _carry_symbols). Run the frame-boundary attach on the
2713
+ # carried map now - at a STALE generation on purpose: the carried sites
2714
+ # predate the merge's changes, so this map washes immediately (verified
2715
+ # sites survive, drifted ones drop) while still reading as "needs a symbol
2716
+ # pass" - the deferred recompute fires and lands at input-quiet.
2717
+ if getattr(code_dict, "_needs_distribute", False):
2718
+ _flat = getattr(code_dict, "symbol_usage", None)
2719
+ code_dict._needs_distribute = False
2720
+ if isinstance(_flat, dict) and _flat:
2721
+ _post_symbol_attach(dict_host, max(0, gen - 1), _flat)
2722
+ return
2723
+ if gen < 1:
2724
+ # Cold store (deleted / first-run pickle): the generation gate only
2725
+ # opens via a warmer build or a src-watch bump, and the periodic
2726
+ # warmer daemon's autostart is disabled (b4a3a9c, perf) - so if no
2727
+ # src edit ever occurs, gen stays 0 and the usage graph would stay
2728
+ # empty FOREVER. Kick exactly one warmer build; its gate bump
2729
+ # (gen 0 -> 1 even with no src changes) unblocks the trigger and
2730
+ # spans then recompute lazily per open tab. Guard on sys so the same
2731
+ # module identities / re-execs share the one-shot.
2732
+ if not getattr(sys, "_symbol_index_cold_kick", False):
2733
+ sys._symbol_index_cold_kick = True
2734
+ _ptrace("ensure-index: cold store, kicking one-off warmer build")
2735
+ _lc.SymbolIndexCache.rebuild()
2736
+ _ptrace_rl("ensure-gen0",
2737
+ "ensure-index: waiting for first warmer build (gen=0)",
2738
+ min_interval=5.0)
2739
+ return # warmer hasn't built yet; we retry once it bumps
2740
+ if jump_to is None and str_host is not None:
2741
+ cs = host_code_state(str_host)
2742
+ jump_to = getattr(cs, "address", None) if cs is not None else None
2743
+ if jump_to is None:
2744
+ return # host hasn't resolved its span yet
2745
+ if dict_host.child_kwargs.get("jump_to") is not jump_to:
2746
+ dict_host.child_kwargs["jump_to"] = jump_to
2747
+ from meltygui.editor.pending_save import PendingSave
2748
+ pgen = PendingSave.pending_gen_for(jump_to.path)
2749
+ key = (id(code_dict), gen, pgen)
2750
+ if getattr(dict_host, "_auto_index_key", None) == key:
2751
+ return # already handled this parse + index gen + pending edit
2752
+
2753
+ # FAST PATH (inline, render thread): exact cache hit or blank-line offset only
2754
+ # — ~sub-ms–2ms, GIL-cheap. Attach next frame so highlights track the edit,
2755
+ # skipping the yield/stagger/background hop. Fires per pending edit so a
2756
+ # newline offsets eagerly instead of waiting for the reparse.
2757
+ _t_probe0 = time.monotonic()
2758
+ flat = _lc.compute_symbol_usages_for_address(jump_to, fast_only=True)
2759
+ if isinstance(flat, getattr(_lc, "InterimUsages", ())):
2760
+ # A real recompute is owed, but a prior (stale/invalid) result exists -
2761
+ # attach it ONCE as a first-paint stopgap so the editor washes
2762
+ # immediately instead of showing nothing (sites verify-recover against
2763
+ # the live buffer in _collect_node_spans; mismatches drop). Then fall
2764
+ # through to the deferred recompute: _post_symbol_attach sees the sig
2765
+ # isn't fresh, stamps _symbol_gen=None, and clears the nudge key, so
2766
+ # the trigger loop keeps firing until the real compute lands.
2767
+ if getattr(dict_host, "_interim_attach_key", None) != key:
2768
+ dict_host._interim_attach_key = key
2769
+ _ptrace(f"ensure-index: interim stale attach (recompute pending, probe "
2770
+ f"{(time.monotonic() - _t_probe0) * 1000:.1f}ms)",
2771
+ host=_host_label(dict_host), names=len(flat.flat))
2772
+ _post_symbol_attach(dict_host, gen, flat.flat)
2773
+ flat = _lc._NEEDS_RECOMPUTE
2774
+ if flat is not _lc._NEEDS_RECOMPUTE:
2775
+ dict_host._auto_index_key = key
2776
+ _ptrace(f"ensure-index: inline attach (probe "
2777
+ f"{(time.monotonic() - _t_probe0) * 1000:.1f}ms)",
2778
+ host=_host_label(dict_host), names=len(flat))
2779
+ _post_symbol_attach(dict_host, gen, flat)
2780
+ return
2781
+
2782
+ # Not linearly offsettable: If this parse is already indexed at the current
2783
+ # index gen, the symbols are correct except for THIS pending edit's positions -
2784
+ # the chain's reparse will refresh them; don't spawn a recompute per keystroke.
2785
+ if getattr(code_dict, "_symbol_gen", None) == gen:
2786
+ dict_host._auto_index_key = key # handled (mark so we don't re-probe/frame)
2787
+ _ptrace("ensure-index: positions left to next reparse (parse already at gen)",
2788
+ host=_host_label(dict_host))
2789
+ return
2790
+
2791
+ # SLOW PATH (gen-stale parse: fresh parse / warmer bump) - a real recompute,
2792
+ # deferred to a background thread behind the no-drag yield + stagger.
2793
+ if not _lc._wait_for_no_drag(max_wait=0.0):
2794
+ _ptrace_rl(("ensure-drag", id(dict_host)),
2795
+ "ensure-index: deferred (mid-drag)", host=_host_label(dict_host))
2796
+ return # mid-gesture - don't even start; retried next frame
2797
+ if time.monotonic() - _last_auto_index_time < _AUTO_INDEX_STAGGER_S:
2798
+ _ptrace_rl(("ensure-stagger", id(dict_host)),
2799
+ "ensure-index: deferred (stagger window)", host=_host_label(dict_host))
2800
+ return # another host nudged recently - stagger, retry later
2801
+ _last_auto_index_time = time.monotonic()
2802
+ dict_host._auto_index_key = key
2803
+ _ptrace("ensure-index: spawning background recompute (gen-stale parse)",
2804
+ host=_host_label(dict_host), gen=gen)
2805
+ threading.Thread(target=_index_host_in_place, args=(str_host, dict_host, gen),
2806
+ daemon=True, name="symbol-index-attach").start()
2807
+
2808
+
2809
+ def _index_host_in_place(str_host, dict_host, gen):
2810
+ """Compute + attach symbol usages onto a host's HELD gp, on a background
2811
+ thread, WITHOUT re-running its chain. A chain re-run would libcst-reparse
2812
+ the whole buffer — at ~0.5s+ of GIL-bound parse per open editor, the
2813
+ original gen-bump kick stacked those into one big render-thread hang right
2814
+ after the warmer's first build. The index compute itself is unavoidable
2815
+ GIL work (it resolves against LIVE objects via _src_mod_map, so unlike the
2816
+ accurate-jedi path it cannot move to the subprocess pool), but it's the
2817
+ small slice — mtime/generation-cached, ~25ms warm. In-place dict writes on
2818
+ the gp are safe here: consumers only re-read after _notify_consumers
2819
+ invalidates their subtrees (the same wake a background parse uses)."""
2820
+ from meltygui.code.libcst_conversion import compute_symbol_usages_for_address
2821
+ from meltygui.code.libcst_conversion import _wait_for_no_drag
2822
+ _t_ih0 = time.monotonic()
2823
+ if not _wait_for_no_drag(label=f"index-host {_host_label(dict_host)}"):
2824
+ # Gesture outlasted the wait - bail rather than steal GIL time from
2825
+ # it. Clearing the in-flight key lets the editor-side nudge (or
2826
+ # the next generation bump) retry once the user lets go.
2827
+ dict_host._auto_index_key = None
2828
+ _ptrace("index-host: bailed (drag outlasted wait)", host=_host_label(dict_host))
2829
+ return
2830
+ address = dict_host.child_kwargs.get("jump_to")
2831
+ if address is None:
2832
+ cs = host_code_state(str_host)
2833
+ address = getattr(cs, "address", None) if cs is not None else None
2834
+ if address is None:
2835
+ _ptrace("index-host: no address resolved yet — skipped",
2836
+ host=_host_label(dict_host))
2837
+ return
2838
+ # Persist for the chain: future reparses attach via cst_module_to_dict.
2839
+ dict_host.child_kwargs["jump_to"] = address
2840
+ try:
2841
+ flat = compute_symbol_usages_for_address(address)
2842
+ except Exception as _e:
2843
+ _ptrace(f"index-host: compute RAISED {type(_e).__name__}: {_e}",
2844
+ host=_host_label(dict_host))
2845
+ return
2846
+ _ptrace(f"index-host: computed in {(time.monotonic() - _t_ih0) * 1000:.0f}ms, posting attach",
2847
+ host=_host_label(dict_host), names=len(flat))
2848
+ # Attach at the next frame, - the gp is walked live every frame and a
2849
+ # mid-walk insert would raise (see _post_symbol_attach). Re-fetches the
2850
+ # held gp there (a reparse may have replaced it; sites are file-absolute).
2851
+ _post_symbol_attach(dict_host, gen, flat)
2852
+
2853
+
2854
+ def _wake_stale_code_hosts(gen):
2855
+ """Index-generation-bump hook (runs on the warmer's daemon thread):
2856
+ refresh the symbol usages of every cached code host whose held parse
2857
+ predates `gen`, without any editor interaction. The editor-side
2858
+ _ensure_symbol_index can't cover this case — cached editor views replay
2859
+ their blit on an idle app, so a bump that happens while nothing is
2860
+ invalidating (right after startup, or an external-IDE edit) would never
2861
+ be observed. Attaches IN PLACE (no chain re-run / libcst reparse — see
2862
+ _index_host_in_place); a small sleep between hosts keeps their index
2863
+ passes from stacking into one GIL burst against the render thread."""
2864
+ if not (Toggles.enable_jedi
2865
+ and Toggles.TextEditor.SymbolUsages.auto_index
2866
+ and not Toggles.jedi_correctness):
2867
+ return
2868
+ _t_wake0 = time.monotonic()
2869
+ _woken = 0
2870
+ hosts = list(_code_host_cache.values())
2871
+ _ptrace("wake-stale-hosts: sweep start (0.25s sleep between hosts)",
2872
+ gen=gen, hosts=len(hosts))
2873
+ for sh, dh in hosts:
2874
+ gp = dh._held()
2875
+ if not isinstance(gp, dict) or getattr(gp, "_symbol_gen", None) == gen:
2876
+ continue
2877
+ if getattr(dh, "_auto_index_key", None) == (id(gp), gen):
2878
+ continue
2879
+ dh._auto_index_key = (id(gp), gen)
2880
+ _woken += 1
2881
+ _index_host_in_place(sh, dh, gen)
2882
+ time.sleep(0.25)
2883
+ _ptrace(f"wake-stale-hosts: sweep done in {(time.monotonic() - _t_wake0) * 1000:.0f}ms",
2884
+ gen=gen, woken=_woken)
2885
+
2886
+
2887
+ def _register_index_bump_hook():
2888
+ import meltygui.code.libcst_conversion as _lc
2889
+ cbs = getattr(_lc, "_index_bump_callbacks", None)
2890
+ if cbs is None:
2891
+ return # older libcst_conversion.py loaded
2892
+ cbs[:] = [cb for cb in cbs
2893
+ if getattr(cb, "__name__", "") != "_wake_stale_code_hosts"]
2894
+ cbs.append(_wake_stale_code_hosts)
2895
+
2896
+
2897
+ _register_index_bump_hook()
2898
+
2899
+
2900
+ def _host_relint_and_fixes(dict_host, _str_host, wds):
2901
+ """The code host's lint-refresh + suggestions pull, shared by BOTH editor
2902
+ routes (draw_text_from_code_cache and the NEW_CODE tabs' text pane).
2903
+ Returns the {line: [import stmts]} for draw_text's import_fixes, or None.
2904
+
2905
+ Lint-only refresh (no reparse): a pending edit anywhere in this FILE (an
2906
+ import removed in another view, a reverted entry) changes what the
2907
+ missing-import lint should report — queue_save sets _relint_pending via
2908
+ _kick_relint and wakes us through the host's consumer registry. Runs
2909
+ check_source + the suggestion scan alone on a worker and swaps
2910
+ ModesState.last_lint / last_imports; callers' marker extraction sees the
2911
+ fresh lists the same frame they land.
2912
+
2913
+ A cst-cache-hit boot skipped both passes entirely (lint_deferred) — that
2914
+ converts into a pending relint here. Launch floor: kicks can arrive per
2915
+ queued keystroke save (echo bursts included); one relint per second per
2916
+ host is plenty — when suppressed the flag stays LATCHED, so a later
2917
+ frame runs the trailing state and the final answer is never lost."""
2918
+ for v in (getattr(wds, "misc", None) or {}).values():
2919
+ if isinstance(v, ModesState) and (getattr(v, '_lint_deferred', False)
2920
+ or not hasattr(dict_host, '_last_relint_t')):
2921
+ v._lint_deferred = False
2922
+ dict_host._relint_pending = True
2923
+ _relint = bool(getattr(dict_host, '_relint_pending', False))
2924
+ if _relint:
2925
+ _rl_now = time.monotonic()
2926
+ if _rl_now - getattr(dict_host, '_last_relint_t', 0.0) < 1.0:
2927
+ _relint = False # retry soon - flag stays set
2928
+ if getattr(dict_host, '_relint_timer', None) is None:
2929
+ import threading
2930
+ def wake_relint():
2931
+ def notify():
2932
+ dict_host._relint_timer = None
2933
+ dict_host._notify_consumers(name='deferred lint ready')
2934
+ request_render()
2935
+ Melty.post_to_render(notify)
2936
+ delay = max(0.01, 1.0 - (_rl_now - dict_host._last_relint_t))
2937
+ dict_host._relint_timer = threading.Timer(delay, wake_relint)
2938
+ dict_host._relint_timer.daemon = True
2939
+ dict_host._relint_timer.start()
2940
+ else:
2941
+ dict_host._relint_pending = False
2942
+ dict_host._last_relint_t = _rl_now
2943
+ _lk = dict_host.child_kwargs.get('run_chain_kwargs') or {}
2944
+ # The runner is a render_func: polling it every frame cost its wrapper
2945
+ # (~0.2 ms) for nothing while idle. Call it only from a start edge
2946
+ # until that run has reported (busy → done), then stop polling.
2947
+ if _relint:
2948
+ dict_host._relint_active = True
2949
+ if (_lk.get('lint_path') and len(_str_host.values()) > 0
2950
+ and getattr(dict_host, '_relint_active', False)):
2951
+ _rl_done, _rl_payload = run_in_background(
2952
+ _run_relint,
2953
+ child_kwargs={'input_value': list(_str_host.values())[0],
2954
+ 'lint_path': _lk.get('lint_path'),
2955
+ 'lint_span': _lk.get('lint_span', False)},
2956
+ name=f"relint{id(dict_host)}", start=_relint, debounce_ms=400)
2957
+ if _rl_done:
2958
+ dict_host._relint_active = False # reported; stop polling
2959
+ if _rl_done and isinstance(_rl_payload, dict):
2960
+ for v in (getattr(wds, "misc", None) or {}).values():
2961
+ if isinstance(v, ModesState):
2962
+ if v.last_lint != _rl_payload["lint"]:
2963
+ v.last_lint = _rl_payload["lint"]
2964
+ _rl_imports = _rl_payload.get("imports") or {}
2965
+ if getattr(v, "last_imports", None) != _rl_imports:
2966
+ v.last_imports = _rl_imports
2967
+ for v in (getattr(wds, "misc", None) or {}).values():
2968
+ if isinstance(v, ModesState):
2969
+ # The suggestions channel rides to draw_text as its own kwarg
2970
+ # (import_fixes) - independent of the error markers.
2971
+ return getattr(v, "last_imports", None) or None
2972
+ return None
2973
+
2974
+
2975
+ def _error_markers(err, lint):
2976
+ """The (line, msg) marker list for the editor: the parse/compile error,
2977
+ then the lint findings. Errors only — import suggestions travel on their
2978
+ own channel (ModesState.last_imports → draw_text's import_fixes)."""
2979
+ markers = []
2980
+ if err is not None:
2981
+ line = (getattr(err, "editor_line", None) or getattr(err, "lineno", None)
2982
+ or getattr(err, "raw_line", None) or 1)
2983
+ msg = (getattr(err, "message", None) or getattr(err, "msg", None)
2984
+ or str(err))
2985
+ markers.append((line, msg))
2986
+ markers += list(lint or ())
2987
+ return markers
2988
+
2989
+
2990
+ def _host_code_tree_error(dict_host):
2991
+ """The dict-host's parse / compile / lint error, normalized to draw_text's
2992
+ code_tree shape ({__error__, __line__, __errors__}), or None when clean.
2993
+
2994
+ The same extraction draw_text_from_code_cache does, memoized on the host by
2995
+ (exception, lint) IDENTITY: draw_text's parse-error staleness check compares
2996
+ code_tree by identity to tell "a fresh parse landed", so a dict rebuilt every
2997
+ frame would un-hide a stale highlight the frame after an edit. last_error /
2998
+ last_lint are swapped per finished parse (never mutated in place), so identity
2999
+ is a safe key."""
3000
+ if dict_host is None:
3001
+ return None
3002
+ wds = getattr(dict_host, "_wrapper_draw_state", None)
3003
+ for v in (getattr(wds, "misc", None) or {}).values():
3004
+ if not isinstance(v, ModesState):
3005
+ continue
3006
+ err = v.last_error
3007
+ lint = getattr(v, "last_lint", None) or None
3008
+ if err is None and not lint:
3009
+ return None
3010
+ memo = getattr(dict_host, "_err_view_memo", None)
3011
+ if memo is not None and memo[0] is err and memo[1] is lint:
3012
+ return memo[2]
3013
+ markers = _error_markers(err, lint)
3014
+ cache_error = {"__error__": markers[0][1], "__line__": markers[0][0], "__errors__": markers}
3015
+ dict_host._err_view_memo = (err, lint, cache_error)
3016
+ return cache_error
3017
+ return None