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,1255 @@
1
+ import ast
2
+ import difflib
3
+ import hashlib
4
+ import inspect
5
+ import os
6
+ import re
7
+ import sys
8
+ import textwrap
9
+ import tokenize
10
+ import types
11
+ from dataclasses import dataclass
12
+ from enum import EnumType
13
+ from pathlib import Path
14
+
15
+ from meltygui.code.fileref import Address
16
+ from meltygui.code.fileref import _evict_linecache
17
+ from meltygui.code.fileref import shift_sibling_linenos
18
+ from meltygui.code.fileref import is_editable_source
19
+ from meltygui.code.fileref import is_writable_file
20
+ from meltygui.code.chain_converters import _ensure_import_lines
21
+ from meltygui.code.chain_converters import _resolve_call_address
22
+ from meltygui.code.chain_converters import _split_span_at_call
23
+ from meltygui.code.chain_converters import DiskSpanText
24
+ from meltygui.core.conversion.bubbling import base_of_bubbling
25
+ from meltygui.code.file_converters import _detect_newline
26
+ from meltygui.core.rendering.core_decoration import Core
27
+
28
+ from meltygui.core.melty import Melty
29
+ from meltygui.core.melty import FileWatch
30
+ from meltygui.core.diagnostics.perf_trace import trace_rl as _ptrace_rl
31
+ from meltygui.graphics.texture_manager import PIL_TO_GL_FORMAT
32
+ from meltygui.graphics.texture_manager import PendingTexture
33
+
34
+ # No GL imports here: ImageCodec.load only DECODES (background thread); the GL
35
+ # upload runs on the UI thread via PendingTexture.pending_upload.
36
+ from PIL import Image
37
+ import io
38
+ import mimetypes
39
+
40
+ NO_DATA = object()
41
+
42
+ extension_to_codec = {}
43
+ type_to_codec = {}
44
+
45
+
46
+ class SaveConflict:
47
+ """Returned by codec.save when the on-disk span no longer matches what was
48
+ last loaded/saved — an external write landed under the (debounced) save.
49
+ Splicing anyway would replace the WRONG lines, so the write is refused;
50
+ code_file_io keeps the edit pending and surfaces the Load / Keep-mine
51
+ conflict UI instead."""
52
+
53
+ def __init__(self, reason):
54
+ self.reason = reason
55
+
56
+ def __repr__(self):
57
+ return f"SaveConflict({self.reason!r})"
58
+
59
+
60
+ def _span_fingerprint(lines):
61
+ """Content hash of a span's lines, line-ending agnostic — the same span
62
+ fingerprints identically whether the lines came from linecache (keep "\\n"),
63
+ a CRLF file split, or a plain split. Used to verify at save time that the
64
+ on-disk span still holds what we last loaded/saved before splicing over it."""
65
+ h = hashlib.sha1()
66
+ for line in lines:
67
+ h.update(line.rstrip("\r\n").encode("utf-8", "surrogatepass"))
68
+ h.update(b"\n")
69
+ return h.hexdigest()
70
+
71
+
72
+ def _source_and_newline(address, source_text=None):
73
+ """The file's full text + dominant newline for a codec load.
74
+
75
+ Normally reads disk. `source_text` (the EXACT text an in-process write just
76
+ produced, handed over by FileWatch.get_self_write_text) is used instead — a
77
+ self-write sync that skips the disk round trip AND the "Loading…" event. The
78
+ span slice + `_span_fingerprint` downstream are identical either way, since
79
+ the recorded text IS what codec.save wrote to the file."""
80
+ if source_text is not None:
81
+ return source_text, _detect_newline(source_text.encode("utf-8", "surrogatepass"))
82
+ data = address.path.read_bytes()
83
+ newline = _detect_newline(data)
84
+ try:
85
+ return data.decode("utf-8"), newline
86
+ except UnicodeDecodeError:
87
+ return data.decode("latin-1"), newline
88
+
89
+
90
+ def _block_is_function(source_lines, name):
91
+ """Does this getsourcelines block actually contain `def <name>`?
92
+
93
+ inspect.findsource trusts co_firstlineno and walks BACKWARD to the nearest
94
+ def-looking line — after an EXTERNAL edit shifted the file (nothing patches
95
+ live linenos for external writes), that lands on a DIFFERENT function or the
96
+ file head, silently. Loading/saving through that wrong span is the
97
+ file-mangling bug, so verify the block names the function we asked for."""
98
+ if not name.isidentifier(): # <lambda> & friends - can't verify
99
+ return True
100
+ pat = re.compile(rf"^\s*(?:async\s+)?def\s+{re.escape(name)}\b")
101
+ return any(pat.match(line) for line in source_lines)
102
+
103
+
104
+ def _reanchor_function(unwrapped, source_file):
105
+ """Re-find a function whose co_firstlineno went stale (an external edit
106
+ shifted the file) by ast-walking the CURRENT file for its __qualname__.
107
+
108
+ Returns (start0, end0, span_lines) — 0-based [start, end) covering the
109
+ decorators + def — and HEALS the live code object's co_firstlineno (set to
110
+ the first decorator line, the compile convention) so subsequent resolves,
111
+ recompiles, and sibling shifts work from truthful coordinates again.
112
+ Returns None when the file doesn't parse (mid-edit), the function is nested
113
+ (`<locals>` — its def isn't addressable by a body walk), or the qualname
114
+ path isn't found."""
115
+ qual = unwrapped.__qualname__
116
+ if "<locals>" in qual or not unwrapped.__name__.isidentifier():
117
+ return None
118
+ try:
119
+ data = Path(source_file).read_bytes()
120
+ try:
121
+ text = data.decode("utf-8")
122
+ except UnicodeDecodeError:
123
+ text = data.decode("latin-1")
124
+ tree = ast.parse(text)
125
+ except (OSError, SyntaxError, ValueError):
126
+ return None
127
+
128
+ node, body = None, tree.body
129
+ parts = qual.split(".")
130
+ for i, part in enumerate(parts):
131
+ last = i == len(parts) - 1
132
+ found = None
133
+ for child in body:
134
+ if last and isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) \
135
+ and child.name == part:
136
+ found = child
137
+ break
138
+ if not last and isinstance(child, ast.ClassDef) and child.name == part:
139
+ found = child
140
+ break
141
+ if found is None:
142
+ return None
143
+ node, body = found, getattr(found, "body", [])
144
+
145
+ decos = getattr(node, "decorator_list", [])
146
+ first_lineno = min([d.lineno for d in decos] + [node.lineno])
147
+ start0, end0 = first_lineno - 1, node.end_lineno
148
+ span_lines = text.splitlines()[start0:end0]
149
+ try:
150
+ unwrapped.__code__ = unwrapped.__code__.replace(co_firstlineno=first_lineno)
151
+ except (AttributeError, TypeError, ValueError):
152
+ pass # read-only code object - the span is still right for this run
153
+ return start0, end0, span_lines
154
+
155
+ # Library-source guard is in address.py (is_editable_source) so the codec and
156
+ # the older file_converters save paths share one gate. Local alias for brevity.
157
+ _is_editable_source = is_editable_source
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class CallSite:
162
+ """The dispatch key for `CallerCodec`: where a function is CALLED, not where
163
+ it's defined. A function object resolves to its `def` span; a `CallSite`
164
+ resolves to the call STATEMENT at `(filename, lineno)`.
165
+
166
+ A plain `(filename, lineno)` tuple can't be type-dispatched (it's just a
167
+ tuple), so we wrap it in a dedicated type and register the codec `for_type`.
168
+ This is the pattern future targeted codecs follow too — e.g. a `ClassAttribute`
169
+ type pointing a codec at a single `name = value` line in a class body.
170
+
171
+ Construct from `caller_site(get_live_frames())` (which already strips the
172
+ render-dispatch frames), then hand the `CallSite` to `code_file_io`."""
173
+ filename: str
174
+ lineno: int
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class Decorations:
179
+ """The dispatch key for `DecorationsCodec`: the DECORATOR block above a class
180
+ or function, edited on its own.
181
+
182
+ `Decorations(Toggles)` resolves to just the `@window(...)` line(s) above
183
+ `class Toggles:` — not the class body (that's `TypeCodec`) and not a call site
184
+ (that's `CallSite`). Same family pattern as `CallSite`: wrap the target so the
185
+ codec can be type-dispatched, then hand it to `code_file_io`.
186
+
187
+ `target` is the live class or function object whose decorators are edited. The
188
+ codec slices out the decorator lines for editing and re-runs the decorators by
189
+ recompiling the WHOLE object (see `_recompile_decorations`)."""
190
+ target: object
191
+
192
+
193
+ def _resync_module_linenos(address, old_lines, new_lines):
194
+ """After a WHOLE-FILE save, shift live co_firstlineno's to match the new text.
195
+
196
+ A SPANNED save knows its single (after_lineno, delta) and calls
197
+ shift_sibling_linenos directly; a whole-file edit can change line counts
198
+ anywhere, so walk the old→new diff and apply one shift per line-count
199
+ change, bottom-up so earlier shifts don't disturb later regions. Without
200
+ this, views rendering LIVE objects from this file (FunctionCodec /
201
+ TypeCodec) resolve stale spans after the save and snap to the nearest def."""
202
+ resync_file_linenos(address.path, old_lines, new_lines)
203
+
204
+
205
+ def resync_file_linenos(path, old_lines, new_lines):
206
+ """Shift live co_firstlineno's in every module loaded from `path` (resolved
207
+ Path) so they match `new_lines`, given they currently match `old_lines`.
208
+
209
+ The address-free body of _resync_module_linenos: also used by
210
+ PendingSave.resolve_external, which moves live coordinates from the
211
+ last-synced text to the current (externally written) disk WITHOUT any
212
+ disk write of its own.
213
+
214
+ The same file can be materialized under several module names (src.lsd.…
215
+ and lsd.… import roots both exist here), each with its OWN function
216
+ objects — shift every matching module, deduped by identity."""
217
+ opcodes = [(i2, (j2 - j1) - (i2 - i1))
218
+ for tag, i1, i2, j1, j2 in
219
+ difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False).get_opcodes()
220
+ if tag != "equal" and (j2 - j1) != (i2 - i1)]
221
+ if not opcodes:
222
+ return
223
+ seen, modules = set(), []
224
+ for m in list(sys.modules.values()):
225
+ f = getattr(m, "__file__", None)
226
+ try:
227
+ if f and id(m) not in seen and Path(f).resolve() == path:
228
+ seen.add(id(m))
229
+ modules.append(m)
230
+ except (OSError, ValueError):
231
+ continue
232
+ for module in modules:
233
+ for after_lineno, delta in sorted(opcodes, reverse=True):
234
+ shift_sibling_linenos(module, path,
235
+ after_lineno=after_lineno, delta=delta,
236
+ include_saved=True)
237
+
238
+
239
+ def register_codec(cls=None, **kwargs):
240
+ def wrap(cls):
241
+ if "ext" in kwargs:
242
+ exts = kwargs["ext"]
243
+ if isinstance(exts, str):
244
+ exts = (exts,) # a bare "png" once iterates CHAR BY CHAR
245
+ for ext in exts:
246
+ # Normalize to Path.suffix's shape (".png", lowercase) so
247
+ # registration matches lookup regardless of how it was written.
248
+ if not ext.startswith("."):
249
+ ext = "." + ext
250
+ extension_to_codec[ext.lower()] = cls
251
+
252
+ if "for_type" in kwargs:
253
+ if isinstance(kwargs["for_type"], tuple):
254
+ for t in kwargs["for_type"]:
255
+ type_to_codec[t] = cls
256
+ else:
257
+ type_to_codec[kwargs["for_type"]] = cls
258
+ return cls
259
+
260
+ if cls is None:
261
+ return wrap
262
+
263
+ return wrap(cls)
264
+
265
+ class Codec:
266
+ name = "Base Codec"
267
+ # Base render kwargs for views of this codec's data - the codec IS the
268
+ # data source, so source-level appearance lives here. core_render merges
269
+ # these in as the LOWEST priority layer (args = codec_kwargs | kwargs;
270
+ # everything overrides here) - EXCEPT `tint`, which never propagates:
271
+ # it is the source COLOR-CODE, consumed explicitly by provenance views
272
+ # (the context menu's draw_param_matrix), not an optional wash.
273
+ render_kwargs = {}
274
+ # Optional view override for this codec's loaded data. None (the default)
275
+ # means "use the type": a str lands in the text view the caller wired,
276
+ # anything else goes through draw_any to the type's default renderer
277
+ # (is_default_for) - so a codec whose load() returns a type that already
278
+ # has a view needs nothing here. Set it only for a type without a default
279
+ # view or to pin a non-default one. See code_file_io's _set_view.
280
+ view_func = None
281
+ # Whether edits to the loaded value round-trip to the file. False (images,
282
+ # mainly - anything save() refuses) makes code_file_io ignore view edits
283
+ # (no dirty / save runner) and reload external changes immediately instead
284
+ # of parking on the merge/conflict banner: nothing local can be saved.
285
+ editable = True
286
+ # Tab glyph (unicode) for files of this type - the editor's tab bar
287
+ # falls back to it if the file's FileMeta entry carries no icon.
288
+ icon = None
289
+
290
+ @staticmethod
291
+ def show_code_buttons(address):
292
+ """The codec's "this is Python source" switch for code_file_io: the
293
+ Run (hotswap) and Index (jedi) buttons, their hotkeys, AND the error
294
+ checking (the code-host parse + syntax/lint/runtime highlights) all
295
+ ride it. Only meaningful where the loaded text is Python, so the base
296
+ says no; TypeCodec (live Python objects) says yes; TextFileCodec says
297
+ yes for .py paths only."""
298
+ return False
299
+
300
+ @classmethod
301
+ def claims(cls, path):
302
+ """Extension routing's content veto: codec_for_path asks the
303
+ extension-matched codec whether the file's BYTES actually decode as
304
+ what the extension promises. Declining (False) drops the file through
305
+ to the content sniff instead — a zero-byte or corrupt "image.png"
306
+ becomes editable text / a binary summary rather than a load() that
307
+ throws on every watch-triggered reload. The base accepts everything;
308
+ only codecs whose load() can reject content (ImageCodec) override."""
309
+ return True
310
+
311
+ @staticmethod
312
+ def resolve_address(input_value, draw_state=None, **kwargs):
313
+ return NO_DATA
314
+
315
+ @staticmethod
316
+ def load(path, **kwargs):
317
+ return NO_DATA
318
+
319
+ @staticmethod
320
+ def save(data, file_path, **kwargs):
321
+ return False
322
+
323
+ # ── Per-file attributes (AppModel.file_meta_collection) ────────────────
324
+ # The codec is an input source (the _ViewSource row / SourcePriority.
325
+ # CODEC). Its class-level render_kwargs are in-memory and codec-wide;
326
+ # per-FILE attributes instead land in FileMetaCollection.file_meta - the
327
+ # DictConversion mirroring the file tree - keyed by the file this
328
+ # element's Address resolves to. Living on AppModel makes persistence
329
+ # automatic (rides the root save), and the folder tree re-applies thes
330
+ # as meta on every run (folder_files._apply_meta).
331
+
332
+ @classmethod
333
+ def file_meta_key(cls, draw_state):
334
+ """The file-meta dict key (path string) for the file this element
335
+ belongs to, memoized on the draw_state's `_file_meta` field. O(1) BY
336
+ DESIGN — this runs in the wrapper's per-render codec layer, so no
337
+ ancestor walk: the ds's OWN codec-stamped Address resolves it, else
338
+ the key propagates one hop from the parent's memo (parents render
339
+ before children, so a subtree under a resolved view fills in top-down
340
+ across frames; an unbounded walk here froze startup — cycles aside,
341
+ it was O(depth) per view per frame across every codec subtree)."""
342
+ key = getattr(draw_state, "_file_meta", None)
343
+ if isinstance(key, str):
344
+ return key
345
+ path = getattr(getattr(draw_state, "_address", None), "path", None)
346
+ if path:
347
+ key = str(path)
348
+ else:
349
+ parent = getattr(draw_state, "_parent", None)
350
+ pkey = getattr(parent, "_file_meta", None) if parent is not None else None
351
+ key = pkey if isinstance(pkey, str) else None
352
+ if key is not None:
353
+ draw_state._file_meta = key
354
+ return key
355
+
356
+ @classmethod
357
+ def file_meta_entry(cls, draw_state, create=False):
358
+ """This file's params dict in the shared file-meta store
359
+ (file_meta_store(), what AppModel.file_meta_collection.file_meta is
360
+ too), or None (no file resolves / no entry and not create).
361
+ create=True materializes the entry."""
362
+ from meltygui.models.file_meta import FileMeta
363
+ from meltygui.models.file_meta import file_meta_store
364
+ meta = file_meta_store()
365
+ path = cls.file_meta_key(draw_state)
366
+ if path is None:
367
+ return None
368
+ entry = meta.get(path)
369
+ if not isinstance(entry, dict):
370
+ if not create:
371
+ return None
372
+ entry = meta[path] = FileMeta()
373
+ return entry
374
+
375
+ @classmethod
376
+ def update_file_meta(cls, draw_state, key, value):
377
+ """Stamp a per-file attribute into the correct file's metadata entry.
378
+ Returns True when the write landed (a file resolved), False when it
379
+ couldn't — the caller falls back to codec-wide behavior."""
380
+ entry = cls.file_meta_entry(draw_state, create=True)
381
+ if entry is None:
382
+ return False
383
+ entry[key] = value
384
+ return True
385
+
386
+
387
+ @register_codec(for_type=(type, EnumType))
388
+ class TypeCodec(Codec):
389
+ # Class source - where @defaults lives: dark grey/blue.
390
+ render_kwargs = {"tint": (0.10, 0.12, 0.22, 0.40)}
391
+
392
+ @staticmethod
393
+ def show_code_buttons(address):
394
+ return True # the data IS live Python source - Run/Index apply
395
+
396
+ @staticmethod
397
+ def resolve_address(input_value, draw_state=None, **kwargs):
398
+ if isinstance(input_value, type) and input_value.__module__ not in ('builtins', '_collections_abc'):
399
+ # A runtime-generated bubbling subclass has no source - resolve its base.
400
+ unwrapped = base_of_bubbling(input_value)
401
+ try:
402
+ source_file = inspect.getfile(unwrapped)
403
+ except TypeError:
404
+ return None
405
+
406
+ # Refuse library source - we only ever edit this project's own code.
407
+ if not _is_editable_source(source_file):
408
+ return None
409
+
410
+ if draw_state is not None:
411
+ FileWatch.register_draw_state(draw_state, Path(source_file))
412
+ else:
413
+ return None
414
+
415
+ try:
416
+ mtime = Path(source_file).stat().st_mtime
417
+ except OSError:
418
+ mtime = None
419
+ cached = getattr(draw_state, '_addr_cache', None)
420
+ if cached is not None and cached[0] is input_value and cached[1] == mtime:
421
+ return cached[2]
422
+
423
+ # Timeline: WHY the cached address lookup stale - this miss path pays a
424
+ # whole Python getsourcelines call on the render thread, so a miss
425
+ # per keystroke (e.g. the trailing disk save moving mtime) is a big hitch.
426
+ _why = ("cold" if cached is None
427
+ else "identity" if cached[0] is not input_value
428
+ else f"mtime {cached[1]}->{mtime}")
429
+ _ptrace_rl(("addr-resolve", id(draw_state)), f"addr re-resolve ({_why})",
430
+ target=getattr(input_value, '__name__', '?'))
431
+ _evict_linecache(source_file)
432
+ try:
433
+ source_lines, start_lineno = inspect.getsourcelines(unwrapped)
434
+ except (OSError, TypeError, tokenize.TokenError, SyntaxError) as e:
435
+ if hasattr(draw_state, '_addr_cache') and draw_state._addr_cache is not None:
436
+ return draw_state._addr_cache[2]
437
+
438
+ print(f"[editable_source] could not resolve {getattr(input_value, '__name__', input_value)}: {e}")
439
+ return None
440
+
441
+ address = Address(Path(source_file), start_lineno - 1,
442
+ start_lineno - 1 + len(source_lines),
443
+ source=unwrapped, watcher_ds=draw_state)
444
+ address._span_fp = _span_fingerprint(source_lines)
445
+ draw_state._addr_cache = (input_value, mtime, address)
446
+ return address
447
+
448
+
449
+ @classmethod
450
+ def load(cls, address, source_text=None, **kwargs):
451
+ # In-memory overlay: a queued-but-unflushed edit for this span (saves
452
+ # defer to shutdown) is the freshest text; return it as the now-stale
453
+ # disk content, and skip the disk read below. An explicit source_text
454
+ # (a verified reload copy of exact disk content) still takes the slice
455
+ # path below. See PendingSave.pending_text_for.
456
+ if source_text is None:
457
+ from meltygui.editor.pending_save import PendingSave
458
+ pending = PendingSave.pending_text_for(address)
459
+ if pending is not None:
460
+ # Re-baseline the save conflict guard against the app's OWN writes.
461
+ # _span_fp (set at first load) means "the disk content this pending
462
+ # edit was derived from". When ANOTHER surface edits the same span
463
+ # it writes via codec.save, and this view's pending text is
464
+ # rebased onto the new disk by the post-write reload - but _span_fp
465
+ # stayed frozen, so save reads the app's own sibling write as an
466
+ # EXTERNAL conflict and refuses it (the multi-surface false
467
+ # conflict). When the new disk is an in-process write
468
+ # (is_self_write), it is exactly what the rebased pending sits on,
469
+ # so sync _span_fp to match. A genuine EXTERNAL write leaves
470
+ # is_self_write False, the baseline stays stale, and the guard
471
+ # still fires - a real conflict.
472
+ if FileWatch.is_self_write(address.path):
473
+ try:
474
+ text, newline = _source_and_newline(address)
475
+ lines = text.split(newline)
476
+ span = (lines if address.start is None
477
+ else lines[address.start:address.end])
478
+ address._span_fp = _span_fingerprint(span)
479
+ except OSError:
480
+ pass
481
+ return pending
482
+ # Launch-baseline overlay (whole-file loads): PENDING is the studio's
483
+ # copy. A file that drifted on disk since the studio last knew it
484
+ # (ExternalChanges.original - seeded from core_project.py'
485
+ # launch preload via the first-time code_cache pop) opens as that
486
+ # LAST-KNOWN text, not the new disk content: external changes are
487
+ # never auto-pulled into pending; they stay visible through the git
488
+ # file_system proxy / compare until manually merged. The baseline
489
+ # fingerprint arms the save conflict guard, so saving this buffer
490
+ # refuses against the newer disk and routes through the merge
491
+ # surface. Span loads skip it - the baseline's line numbering can't
492
+ # be trusted for a post-drift span address; self-writes skip it
493
+ # - the disk already IS the studio's own text.
494
+ if source_text is None and address.start is None \
495
+ and not FileWatch.is_self_write(address.path):
496
+ from meltygui.editor.external_changes import ExternalChanges
497
+ try:
498
+ _res = str(address.path.resolve())
499
+ except OSError:
500
+ _res = None
501
+ base = ExternalChanges.originals.get(_res) if _res else None
502
+ if isinstance(base, str) and base:
503
+ address._span_fp = _span_fingerprint(base.split("\n"))
504
+ return base
505
+ text, newline = _source_and_newline(address, source_text)
506
+ lines = text.split(newline)
507
+ span_lines = lines[address.start:address.end]
508
+ # Remember what the span held when it was loaded; save verifies the disk
509
+ # still holds this before splicing over it (see save's conflict guard).
510
+ address._span_fp = _span_fingerprint(span_lines)
511
+ out = newline.join(span_lines)
512
+ # Plain disk read (no pending overlay reached this path): stamp the
513
+ # text with the mtime it reflects so the CST's cache can serve /
514
+ # store its parse with no content comparison (see DiskSpanText). Any
515
+ # edit decays it to plain str. An explicit source_text is a VERIFIED
516
+ # self-write of exact disk content, so it carries provenance too.
517
+ if source_text is None or FileWatch.is_self_write(address.path):
518
+ try:
519
+ stamped = DiskSpanText(out)
520
+ stamped._disk_mtime = address.path.stat().st_mtime
521
+ stamped._disk_span = (os.path.realpath(str(address.path)),
522
+ address.start, address.end)
523
+ # Value-carried provenance: the loaded text knows its codec,
524
+ # so rendering it OUTSIDE this codec's own subtree (the
525
+ # the an editor drawing host["value"] with draw_text)
526
+ # still re-establishes the codec context - and with it the
527
+ # per-file kwargs layer. See core_render's codec_scope.
528
+ stamped._codec = cls
529
+ return stamped
530
+ except OSError:
531
+ pass
532
+ return out
533
+
534
+
535
+ @staticmethod
536
+ def save(address, data, ensure_import=None, force=False, **kwargs):
537
+ """Write code_str back into the file at the Address's span — the synchronous
538
+ body of the old @render_func(background=True) _do_save, minus the Pending.
539
+
540
+ ensure_import=(module, name) inserts a missing import in the SAME write so a
541
+ synthesized decorator (e.g. @defaults) resolves. Updates the Address span in
542
+ place and shifts siblings so this frame's Address stays valid; siblings heal
543
+ on the next mtime-driven re-resolve.
544
+
545
+ Refuses the write (returns SaveConflict) when the on-disk span no longer
546
+ fingerprints to what was last loaded/saved there — an external program
547
+ wrote the file under us, and splicing would land on the wrong lines.
548
+ force=True (the user's explicit "Keep mine") skips that guard."""
549
+ # Defense in depth: never write to library source even if an Address
550
+ # somehow points outside the project (resolve_address should already have
551
+ # refused it). A bad span splice bug once corrupted libcst's own source.
552
+ # `_allow_write` is the whole-file opt-out: TextFileCodec stamps it on
553
+ # Addresses it resolved through the gentler is_writable_source gate
554
+ # (folder windows mount paths outside the project), so whole-file saves
555
+ # there pass while code codecs stay pinned to the project tree.
556
+ if not (_is_editable_source(address.path)
557
+ or getattr(address, "_allow_write", False)):
558
+ print(f"[codec.save] refusing to write library source: {address.path}")
559
+ return False
560
+ full = address.path.read_bytes()
561
+ newline = _detect_newline(full)
562
+ try:
563
+ text = full.decode("utf-8")
564
+ except UnicodeDecodeError:
565
+ text = full.decode("latin-1")
566
+ lines = text.split(newline)
567
+ # `data` must use the SAME span convention as load(): a span of N lines is
568
+ # N elements joined by N-1 newlines, with no trailing newline. If the
569
+ # editor (or a cst round-trip) hands us a trailing newline, splitting it
570
+ # yields a phantom empty element - which both splices a spurious blank line
571
+ # into the file AND inflates the line-count delta, so siblings below
572
+ # over-shift by one. That drift accumulates per save until findsource's
573
+ # backward walk lands on the wrong def and the view loses its reference.
574
+ # Stick to the load convention: strip exactly one trailing newline.
575
+ if data.endswith(newline):
576
+ data = data[:-len(newline)]
577
+ new_lines = data.split(newline)
578
+
579
+ old_start, old_end = address.start, address.end
580
+
581
+ # ─── Verify before splice ──────────────────────────────────────────────
582
+ # The span coordinates were resolved on the render thread, possibly
583
+ # hundreds of ms before this (debounced, background) save. If anything
584
+ # else wrote the file in between, our coordinates index DIFFERENT
585
+ # content and splicing would mangle the result (duplicate the function,
586
+ # tear lines). Check the span still holds what we last loaded/saved;
587
+ # on mismatch abort, and let the conflict UI sort it out.
588
+ expected_fp = getattr(address, "_span_fp", None)
589
+ if not force and expected_fp is not None:
590
+ on_disk = lines if old_start is None else lines[old_start:old_end]
591
+ if _span_fingerprint(on_disk) != expected_fp:
592
+ print(f"[codec.save] refused: {address.path.name}"
593
+ f"[{old_start}:{old_end}] changed on disk since load")
594
+ return SaveConflict(f"{address.path.name} changed on disk")
595
+
596
+ if old_start is None: # whole-file (module) address
597
+ old_lines = lines # pre-splice contents, for the lineno resync below
598
+ lines = new_lines
599
+ else:
600
+ lines[old_start:old_end] = new_lines
601
+
602
+ inserted = 0
603
+ insert_idx = None
604
+ if ensure_import is not None:
605
+ # Three shapes: (module, name) - the legacy @defaults;; a full
606
+ # statement string ("import numpy as np" - the editor's
607
+ # missing-import quick-fix); or a list of either (several fixes
608
+ # queued on one entry before the flush).
609
+ _eis = (ensure_import if isinstance(ensure_import, list)
610
+ else [ensure_import])
611
+ for _ei in _eis:
612
+ if isinstance(_ei, str):
613
+ lines, _ins, _idx = _ensure_import_lines(lines, _ei)
614
+ else:
615
+ lines, _ins, _idx = _ensure_import_lines(lines, _ei[0], _ei[1])
616
+ inserted += _ins
617
+ if _idx is not None:
618
+ insert_idx = _idx if insert_idx is None else min(insert_idx, _idx)
619
+
620
+ final_text = newline.join(lines)
621
+
622
+ # Patch live co_firstlineno's BEFORE the write, not after. Other views of
623
+ # this file reload on the mtime bump (FileWatch dispatch / auto_load_edits
624
+ # on the render thread) and re-resolve our span - resolve_address caches
625
+ # per (input, mtime), so a resolve that races a post-write shift sees the
626
+ # NEW file with the OLD linenos, walks findsource back to the wrong def,
627
+ # and pins that wrong span for the new mtime (nothing busts it until the
628
+ # NEXT save). Pre-write updates are safe in the other direction: until the
629
+ # write below bumps mtime, the resolve is pulled from the cache, so the
630
+ # transient "old file + new linenos" state is never observed.
631
+ if old_start is not None:
632
+ resolved_old_end = old_end if old_end is not None else old_start + len(new_lines)
633
+ new_end = old_start + len(new_lines)
634
+ delta = new_end - resolved_old_end
635
+ address.end = new_end
636
+ if inserted: # import landed above our span
637
+ address.start = old_start + inserted
638
+ address.end = new_end + inserted
639
+ # The shift anchor is normally the span's live source; an entry
640
+ # whose source is deliberately NON-code (like auto-import insertion
641
+ # - a plain marker string so recompile_all never hotswaps it) can
642
+ # carry the module to shift as `_shift_source` instead.
643
+ _shift_src = getattr(address, "_shift_source", None) or address.source
644
+ # Shift siblings below for the body span change (original coords)...
645
+ shift_sibling_linenos(_shift_src, address.path,
646
+ after_lineno=resolved_old_end, delta=delta)
647
+ # ...then the import insert moved our def AND everything below it down,
648
+ # so shift the saved source too (include_saved) - else its own
649
+ # co_firstlineno goes stale and the next resolve walks back to line 0.
650
+ if inserted and insert_idx is not None:
651
+ shift_sibling_linenos(_shift_src, address.path,
652
+ after_lineno=insert_idx, delta=inserted,
653
+ include_saved=True)
654
+ else:
655
+ # Whole-file address: the splice gave us a single delta, so resync
656
+ # live line numbers from the old→new diff (see the helper).
657
+ _resync_module_linenos(address, old_lines, lines)
658
+
659
+ # Stamp the watch hash BEFORE writing so our own write doesn't read back as a
660
+ # stale external change.
661
+ FileWatch.set_hash_from_content(address.path, final_text, draw_state=address._watcher_ds)
662
+ address.path.write_text(final_text, encoding="utf-8")
663
+ # The on-disk span is now exactly what we wrote - refresh the conflict
664
+ # guard's baseline so the next save verifies against THIS write.
665
+ address._span_fp = _span_fingerprint(new_lines if old_start is not None else lines)
666
+
667
+ # Serve any post-write resolve from cache. This save kept address.start/end
668
+ # truthful, while a getsourcelines re-resolve of the file we JUST wrote
669
+ # can silently TRUNCATE: a broken (column-0) line in the saved buffer ends
670
+ # inspect's block scan at the dedent with no exception, so the raise-guard
671
+ # in resolve_address never fires - and the next save would splice the full
672
+ # buffer into the short span, truncating the tail. Bumping the cached mtime
673
+ # to the written value keeps the save-maintained address authoritative; a
674
+ # GENUINE external write bumps mtime again and still re-resolves.
675
+ ds = address._watcher_ds
676
+ cached = getattr(ds, "_addr_cache", None) if ds is not None else None
677
+ if cached is not None and cached[2] is address:
678
+ try:
679
+ ds._addr_cache = (cached[0], address.path.stat().st_mtime, address)
680
+ except OSError:
681
+ pass
682
+ return False
683
+
684
+ # Register AFTER TypeCodec so EnumType re-matches here: an enum class (Mode,
685
+ # Toggles-style flag enums, etc) is an EnumMeta instance, so its MRO hits
686
+ # EnumType before type. Same render/load/save as any class - the subclass only
687
+ # claims the source render, so mode-backed entries (the inputs tab's mode
688
+ # column) are distinguishable from plain class source at a glance.
689
+ @register_codec(for_type=EnumType)
690
+ class ModeCodec(TypeCodec):
691
+ name = "Enum / Mode"
692
+ # Mode entry kwargs - the mode column of the inputs matrix: purple.
693
+ render_kwargs = {"tint": (0.36, 0.16, 0.50, 0.50)}
694
+
695
+
696
+ @register_codec(for_type=types.FunctionType)
697
+
698
+ class FunctionCodec(TypeCodec):
699
+ name = "Python Function"
700
+ # The render function's own source (def + body): green.
701
+ render_kwargs = {"tint": (0.04, 0.45, 0.12, 0.0)}
702
+
703
+ @staticmethod
704
+ def resolve_address(input_value, draw_state=None, **kwargs):
705
+ if isinstance(input_value, types.FunctionType):
706
+ unwrapped = inspect.unwrap(input_value)
707
+ try:
708
+ source_file = inspect.getfile(unwrapped)
709
+ except TypeError:
710
+ return None
711
+
712
+ # Refuse library source - we only ever edit this project's own code.
713
+ if not _is_editable_source(source_file):
714
+ return None
715
+
716
+ if draw_state is not None:
717
+ FileWatch.register_draw_state(draw_state, Path(source_file))
718
+ else:
719
+ return None
720
+
721
+ try:
722
+ mtime = Path(source_file).stat().st_mtime
723
+ except OSError:
724
+ mtime = None
725
+ cached = getattr(draw_state, '_addr_cache', None)
726
+ if cached is not None and cached[0] is input_value and cached[1] == mtime:
727
+ return cached[2]
728
+
729
+ # Same miss-reason timeline as TypeCodec.resolve_address above.
730
+ _why = ("cold" if cached is None
731
+ else "identity" if cached[0] is not input_value
732
+ else f"mtime {cached[1]}->{mtime}")
733
+ _ptrace_rl(("addr-resolve", id(draw_state)), f"addr re-resolve ({_why})",
734
+ target=getattr(input_value, '__name__', '?'))
735
+ _evict_linecache(source_file)
736
+ try:
737
+ source_lines, start_lineno = inspect.getsourcelines(unwrapped)
738
+
739
+ except (OSError, TypeError, tokenize.TokenError, SyntaxError) as e:
740
+ if draw_state._addr_cache is not None:
741
+ return draw_state._addr_cache[2]
742
+
743
+ print(f"[editable_source] could not resolve {getattr(input_value, '__name__', input_value)}: {e}")
744
+ return None
745
+
746
+ # The function may resolve to a DIFFERENT line than its cached address because
747
+ # that's the legitimate sibling-shift case: editing another function in the
748
+ # same file moved this one up/down, and codec.save already patched this
749
+ # function's co_firstlineno (via shift_sibling_linenos) so getsourcelines
750
+ # now returns the truthful new span. Trust it and re-cache the address;
751
+ # rejecting it here would strand any OTHER editor window open on the same
752
+ # file (they'd lose their reference the instant a sibling is edited).
753
+ #
754
+ # But an EXTERNAL edit shifts the file without patching anybody's lineno -
755
+ # findsource then walks back from the stale lineno to whatever def-head
756
+ # line precedes it and hands us a block that ISN'T this function (just the
757
+ # wrong head). Verify the block matches us; if not, re-anchor by ast on the
758
+ # current line (which also heals co_firstlineno), falling back to the LAST
759
+ # good address when the file is mid-edit and won't parse.
760
+ start0 = start_lineno - 1
761
+ span_lines = source_lines
762
+ if not _block_is_function(source_lines, unwrapped.__name__):
763
+ span = _reanchor_function(unwrapped, source_file)
764
+ if span is None:
765
+ if cached is not None:
766
+ return cached[2]
767
+ print(f"[editable_source] {getattr(input_value, '__name__', input_value)} "
768
+ f"not found at its recorded line and could not be re-anchored")
769
+ return None
770
+ start0, end0, span_lines = span
771
+ print(f"[editable_source] re-anchored {unwrapped.__name__} to "
772
+ f"{Path(source_file).name}:{start0 + 1} after external edit")
773
+ elif span_lines and span_lines[0].lstrip().startswith(("def ", "async def")):
774
+ # A DEF-ANCHORED span (a span-recompile sets co_firstlineno to the
775
+ # def line; a full-module compile sets it to the first decorator)
776
+ # EXCLUDES the decorator lines entirely - the @render_func /
777
+ # @defaults source then parses EMPTY and its inputint row shows
778
+ # nothing (the function_dropdown missing-tint-source bug). Re-anchor
779
+ # by ast, which returns the decorator-INCLUSIVE span and heals
780
+ # co_firstlineno; a genuinely undecorated function re-anchors to the
781
+ # wrong lines, so only accept the result when it actually gained a
782
+ # decorator. Cached per (ref, mtime) like the rest of resolve.
783
+ _dspan = _reanchor_function(unwrapped, source_file)
784
+ if (_dspan is not None and _dspan[2]
785
+ and _dspan[2][0].lstrip().startswith("@")):
786
+ start0, _dend0, span_lines = _dspan
787
+
788
+ address = Address(Path(source_file), start0, start0 + len(span_lines),
789
+ source=input_value, watcher_ds=draw_state)
790
+ address._span_fp = _span_fingerprint(span_lines)
791
+ draw_state._addr_cache = (input_value, mtime, address)
792
+ return address
793
+
794
+
795
+ @register_codec(for_type=CallSite)
796
+ class CallerCodec(TypeCodec):
797
+ """Edit the call EXPRESSION at a CallSite — just `foo(...)`, not the statement
798
+ around it.
799
+
800
+ `resolve_address` ast-walks the file for the outermost Call covering the site's
801
+ line (attaching its column span as `_call_cols` and the enclosing function as
802
+ `.source`). `load`/`save` then slice out the bare call using those columns and
803
+ keep the surrounding text (e.g. the `if `/`[0]:` of `if button(...)[0]:`, or the
804
+ `return ` of `return foo()`) as prefix/suffix to splice an edit back between.
805
+
806
+ Why the call expression and not the whole line (like FunctionCodec's def span):
807
+ a statement HEADER (`if foo():`, `for x in foo():`) or a non-module-level
808
+ statement is not independently parseable — wrapping it in a dummy function
809
+ doesn't help (`if foo():` still needs a body). A bare call expression always
810
+ parses, surfaces as an editable CallParse, and round-trips cleanly. The
811
+ prefix/suffix are stable (the user edits only the call), so they stay valid even
812
+ through half-typed states where the call's columns drift."""
813
+ name = "Python Call Site"
814
+ # Caller kwargs at the call site: the call is teal.
815
+ render_kwargs = {"tint": (0.05, 0.14, 0.20, 0.60)}
816
+
817
+ @staticmethod
818
+ def resolve_address(input_value, draw_state=None, **kwargs):
819
+ if not isinstance(input_value, CallSite):
820
+ return None
821
+ filename, lineno = input_value.filename, input_value.lineno
822
+ if filename is None or lineno is None:
823
+ return None
824
+
825
+ # Refuse library source - we only ever edit this project's own code.
826
+ if not _is_editable_source(filename):
827
+ return None
828
+
829
+ path = Path(filename)
830
+ if draw_state is not None:
831
+ FileWatch.register_draw_state(draw_state, path)
832
+
833
+ try:
834
+ mtime = path.stat().st_mtime
835
+ except OSError:
836
+ mtime = None
837
+ # Cache by (filename, lineno, mtime) - a fresh CallSite is passed each frame,
838
+ # so compare by VALUE, not identity (FunctionCodec can use `is` on the stable
839
+ # function object; we don't). Re-resolves only when the file changes.
840
+ cached = getattr(draw_state, '_addr_cache', None)
841
+ if cached is not None and cached[0] == (filename, lineno) and cached[1] == mtime:
842
+ return cached[2]
843
+
844
+ _evict_linecache(str(path))
845
+ # ast.walk + loop (in C, single-digit ms) finds the call span and attaches
846
+ # the enclosing function as .source - see _resolve_call_address. NOT libcst's
847
+ # PositionTracker, which is O(whole file) pure-Python and stalls the loop.
848
+ address = _resolve_call_address((filename, lineno))
849
+
850
+ # _resolve_call_address NEVER raises and never returns None - when ast.parse
851
+ # fails (the file is mid-edit / syntactically invalid as the user types), it
852
+ # returns a degenerate ONE-LINE fallback span with no `_call_cols`. Saving
853
+ # the multi-line buffer into that 1-line span splices the extra lines IN,
854
+ # appending a copy of the call's continuation lines on every keystroke-save.
855
+ # So treat a missing `_call_cols` as "could not resolve" and keep the last
856
+ # good address - its span matches what our last save wrote, so the next save
857
+ # replaces in place. Mirrors FunctionCodec's getsourcelines-raised guard;
858
+ # there, resolve error surfaces as an exception, here as a flag and span.
859
+ resolved = isinstance(address, Address) and getattr(address, "_call_cols", None) is not None
860
+ if not resolved:
861
+ return cached[2] if cached is not None else None
862
+
863
+ # _resolve_call_address sets the call's prefix/suffix on every valid resolve.
864
+ # As a safety net (e.g. if its inner split ever fails), carry forward the
865
+ # last good ones - they're stable (the user edits only the call, never the
866
+ # `if `/`[0]:` around it), so they stay valid across re-resolves.
867
+ if (not hasattr(address, "_call_prefix")
868
+ and cached is not None and hasattr(cached[2], "_call_prefix")):
869
+ address._call_prefix = cached[2]._call_prefix
870
+ address._call_suffix = cached[2]._call_suffix
871
+
872
+ address._watcher_ds = draw_state
873
+ if draw_state is not None:
874
+ draw_state._addr_cache = ((filename, lineno), mtime, address)
875
+ return address
876
+
877
+ @staticmethod
878
+ def load(address, source_text=None, **kwargs):
879
+ """Load just the call EXPRESSION (not the whole statement). Slice the bare
880
+ call out of its line span using `_call_cols`, and stash the surrounding
881
+ prefix/suffix on the address so save can splice an edit back between them.
882
+ The bare call always parses; the statement around it may not."""
883
+ text, newline = _source_and_newline(address, source_text)
884
+ span_lines = text.split(newline)[address.start:address.end]
885
+ # Conflict-guard baseline (see TypeCodec.save): the FULL span as loaded -
886
+ # save rebuilds prefix + call + suffix, so it verifies at line level.
887
+ address._span_fp = _span_fingerprint(span_lines)
888
+ cols = getattr(address, "_call_cols", None)
889
+ if cols is not None and span_lines:
890
+ prefix, call_text, suffix = _split_span_at_call(span_lines, cols[0], cols[1], newline)
891
+ address._call_prefix = prefix
892
+ address._call_suffix = suffix
893
+ return call_text
894
+ # No column info (resolution was a line fallback) - no span, no splice.
895
+ address._call_prefix = ""
896
+ address._call_suffix = ""
897
+ return newline.join(span_lines)
898
+
899
+ @staticmethod
900
+ def save(address, data, ensure_import=None, **kwargs):
901
+ """Splice the edited call expression back between its stored prefix/suffix
902
+ (captured by load), reconstructing the full statement, then write the whole
903
+ span via TypeCodec.save (which handles the line splice + sibling shift)."""
904
+ prefix = getattr(address, "_call_prefix", "")
905
+ suffix = getattr(address, "_call_suffix", "")
906
+ newline = _detect_newline(address.path.read_bytes())
907
+ # Drop one trailing newline the editor / cst round-trip may append, so the
908
+ # suffix doesn't get pushed onto a phantom next line.
909
+ if data.endswith(newline):
910
+ data = data[:-len(newline)]
911
+ elif data.endswith("\n"):
912
+ data = data[:-1]
913
+ full = prefix + data + suffix
914
+ return TypeCodec.save(address=address, data=full, ensure_import=ensure_import, **kwargs)
915
+
916
+
917
+ def _resolve_decoration_span(target):
918
+ """((path, deco_start, deco_end), unwrapped) for the DECORATOR block above a
919
+ class/function — 0-based [start, end) file lines covering only the `@...`
920
+ lines, NOT the def/class or its body.
921
+
922
+ ast-parses the dedented object source (decorators + def, always parseable) to
923
+ get each decorator's precise line span, so a multi-line `@deco(\n ...\n)` is
924
+ covered exactly. When the object has NO decorators, returns a zero-length span
925
+ pinned to the def/class line, so a save splices a brand-new decorator in right
926
+ above it (the "+ add" path). Raises on an unreadable/invalid file (caller keeps
927
+ the last good address, mirroring FunctionCodec)."""
928
+ unwrapped = inspect.unwrap(target) if isinstance(target, types.FunctionType) else target
929
+ source_file = inspect.getfile(unwrapped)
930
+ _evict_linecache(source_file)
931
+ # getsourcelines starts at the FIRST decorator (1-based start_lineno) for a
932
+ # decorated object, or at the def/class line when undecorated.
933
+ source_lines, start_lineno = inspect.getsourcelines(unwrapped)
934
+ tree = ast.parse(textwrap.dedent("".join(source_lines)))
935
+ node = tree.body[0] # the def/class - block coords are 1-based, line 1 = source_lines[0]
936
+ decos = getattr(node, "decorator_list", [])
937
+ base0 = start_lineno - 1 # file line (0-based) of source_lines[0]
938
+ if decos:
939
+ first = min(d.lineno for d in decos)
940
+ last = max(getattr(d, "end_lineno", d.lineno) for d in decos)
941
+ deco_start, deco_end = base0 + (first - 1), base0 + last
942
+ else:
943
+ pos = base0 + (node.lineno - 1) # the def/class line - empty span above it
944
+ deco_start = deco_end = pos
945
+ return (Path(source_file), deco_start, deco_end), unwrapped
946
+
947
+
948
+ @register_codec(for_type=Decorations)
949
+ class DecorationsCodec(TypeCodec):
950
+ """Edit the DECORATOR block above a class or function — just the `@...` lines.
951
+
952
+ Like `CallerCodec`, only `resolve_address` differs from `TypeCodec`: it points
953
+ the Address at the decorator lines instead of the whole def/class. `load`/`save`
954
+ are inherited and operate on that line span as text (the decorators are full
955
+ lines, so no column splicing is needed — unlike a call embedded in a larger
956
+ statement). `.source` is the wrapped target object, so the inherited save shifts
957
+ siblings correctly; recompile re-runs the decorators by rebuilding the whole
958
+ object (`_recompile_decorations`).
959
+
960
+ An undecorated target resolves to a zero-length span pinned above its def/class
961
+ line: load returns "", and saving typed text splices a fresh decorator in."""
962
+ name = "Python Decorations"
963
+ # Decorator blocks (@window / @render_func / @defaults): lighter blue.
964
+ render_kwargs = {"tint": (0.20, 0.42, 0.75, 0.50)}
965
+
966
+ @staticmethod
967
+ def resolve_address(input_value, draw_state=None, **kwargs):
968
+ if not isinstance(input_value, Decorations):
969
+ return None
970
+ # A runtime-generated bubbling subclass has no source; resolve its base.
971
+ target = base_of_bubbling(input_value.target)
972
+ if not isinstance(target, (type, types.FunctionType)):
973
+ return None
974
+ try:
975
+ source_file = inspect.getfile(
976
+ inspect.unwrap(target) if isinstance(target, types.FunctionType) else target)
977
+ except TypeError:
978
+ return None
979
+ # Refuse library source - we only ever edit this project's own code.
980
+ if not _is_editable_source(source_file):
981
+ return None
982
+
983
+ path = Path(source_file)
984
+ if draw_state is not None:
985
+ FileWatch.register_draw_state(draw_state, path)
986
+
987
+ try:
988
+ mtime = path.stat().st_mtime
989
+ except OSError:
990
+ mtime = None
991
+ # Cache by (target, mtime): a fresh Decorations wraps the SAME stable target
992
+ # each frame, so compare the target by identity (like FunctionCodec). Re-
993
+ # resolves only if the file changes.
994
+ cached = getattr(draw_state, "_addr_cache", None)
995
+ if cached is not None and cached[0] is target and cached[1] == mtime:
996
+ return cached[2]
997
+
998
+ try:
999
+ (p, start, end), _unwrapped = _resolve_decoration_span(target)
1000
+ except (OSError, TypeError, tokenize.TokenError, SyntaxError, ValueError) as e:
1001
+ # File mid-edit / unreadable - keep the last good address so a save
1002
+ # replaces in-place rather than writing to a bad span. Mirrors
1003
+ # FunctionCodec's getsourcelines fallback.
1004
+ if cached is not None:
1005
+ return cached[2]
1006
+ print(f"[DecorationsCodec] could not resolve {getattr(target, '__name__', target)}: {e}")
1007
+ return None
1008
+
1009
+ address = Address(p, start, end, source=target, watcher_ds=draw_state)
1010
+ if draw_state is not None:
1011
+ draw_state._addr_cache = (target, mtime, address)
1012
+ return address
1013
+
1014
+
1015
+ @register_codec(for_type=types.ModuleType)
1016
+ class ModuleCodec(TypeCodec):
1017
+ name = "Python Module"
1018
+ # No source highlighting yet (don't inherit TypeCodec's class blue tint).
1019
+ render_kwargs = {}
1020
+
1021
+ @staticmethod
1022
+ def resolve_address(input_value, draw_state=None, **kwargs):
1023
+ if not isinstance(input_value, types.ModuleType):
1024
+ return None
1025
+ source_file = Path(input_value.__file__)
1026
+ # Refuse library source - we only ever edit this project's own code.
1027
+ if not _is_editable_source(source_file):
1028
+ return None
1029
+ if draw_state is not None:
1030
+ FileWatch.register_draw_state(draw_state, source_file)
1031
+ return Address(source_file, source=input_value, watcher_ds=draw_state)
1032
+
1033
+
1034
+ @register_codec(ext=(".py", ".md", ".txt", ".json", ".toml", ".yaml", ".yml",
1035
+ ".sh", ".cfg", ".ini", ".glsl", ".frag", ".vert"))
1036
+ class TextFileCodec(TypeCodec):
1037
+ """Whole-file editing for a plain text Path — the no-span Address case.
1038
+
1039
+ A spanless Address (start/end None) already means "the whole file" to the
1040
+ inherited TypeCodec load/save, so this codec is just address resolution:
1041
+ point at the file, register the watcher, cache by mtime."""
1042
+ name = "Text File"
1043
+
1044
+ @staticmethod
1045
+ def show_code_buttons(address):
1046
+ # One codec serves any text extension - only .py is runnable/importable.
1047
+ return address is not None and address.path.suffix.lower() == ".py"
1048
+
1049
+ @staticmethod
1050
+ def resolve_address(input_value, draw_state=None, **kwargs):
1051
+ path = Path(str(input_value))
1052
+ # Whole-file text editing follows the gentler gate: folder windows
1053
+ # mount dirs outside the project, so anywhere under $HOME works (for
1054
+ # library installs) - not just the project tree.
1055
+ if not is_writable_file(path) or not path.is_file():
1056
+ return None
1057
+ if draw_state is not None:
1058
+ FileWatch.register_draw_state(draw_state, path)
1059
+ try:
1060
+ mtime = path.stat().st_mtime
1061
+ except OSError:
1062
+ mtime = None
1063
+ cached = getattr(draw_state, '_addr_cache', None)
1064
+ if cached is not None and cached[0] == input_value and cached[1] == mtime:
1065
+ return cached[2]
1066
+ address = Address(path, source=input_value, watcher_ds=draw_state)
1067
+ address._allow_write = True # resolved via is_writable_file - see save() guard
1068
+ if draw_state is not None:
1069
+ draw_state._addr_cache = (input_value, mtime, address)
1070
+ return address
1071
+
1072
+
1073
+ def _resolve_plain_file(input_value, draw_state, **kwargs):
1074
+ """Shared resolve for read-only plain-file codecs (images, binaries):
1075
+ is_writable_file gate and the (input, mtime) address cache — the same
1076
+ shape as TextFileCodec.resolve_address MINUS the FileWatch registration.
1077
+ These files never join the inotify watch: registering MD5s the whole
1078
+ file as a change baseline (a full read of every opened image) and
1079
+ schedules the file's directory as its own inotify INSTANCE (watchdog
1080
+ opens one per scheduled dir; the per-user cap is 128, shared with every
1081
+ other app). External changes are still noticed — code_file_io stats
1082
+ mtime/size every body run (CodeState.is_file_stale) and, for a
1083
+ read-only codec, reloads outright."""
1084
+ path = Path(str(input_value))
1085
+ if not is_writable_file(path) or not path.is_file():
1086
+ return None
1087
+ try:
1088
+ mtime = path.stat().st_mtime
1089
+ except OSError:
1090
+ mtime = None
1091
+ cached = getattr(draw_state, '_addr_cache', None)
1092
+ if cached is not None and cached[0] == input_value and cached[1] == mtime:
1093
+ return cached[2]
1094
+ address = Address(path, source=input_value, watcher_ds=draw_state)
1095
+ if draw_state is not None:
1096
+ draw_state._addr_cache = (input_value, mtime, address)
1097
+ return address
1098
+
1099
+
1100
+ @register_codec(ext=(".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tga"))
1101
+ class ImageCodec(Codec):
1102
+ """Image Path → PendingTexture. load() runs on code_file_io's background
1103
+ thread, so it only DECODES (PIL, safe off-thread) and returns a
1104
+ PendingTexture; core_render's wrapper calls pending_upload() on the GL
1105
+ thread the first frame it renders, and draw_pending_texture (the type's
1106
+ default renderer) draws the uploaded texture with zoom/pan. Read-only
1107
+ (editable=False): view edits never dirty the host and save() refuses."""
1108
+
1109
+ name = "Image"
1110
+ # PendingTexture has a default renderer (draw_pending_texture is
1111
+ # register_default_for_type), so no draw_func: type routing finds it.
1112
+ editable = False
1113
+ icon = "\uf03e" # FA image
1114
+ resolve_address = staticmethod(_resolve_plain_file)
1115
+
1116
+ # claims() runs in the render thread (codec_for_path is re-ried ever
1117
+ # frame), so the PIL header probe is memoized per path and only re-runs
1118
+ # when mtime or size moves - the same staleness key resolve_address uses.
1119
+ _claims_cache = {}
1120
+
1121
+ @classmethod
1122
+ def claims(cls, path):
1123
+ try:
1124
+ s = path.stat()
1125
+ except OSError:
1126
+ # Missing/unreadable: accept and let resolve_address return None -
1127
+ # claims veto is only about content, not existence.
1128
+ return True
1129
+ cached = cls._claims_cache.get(str(path))
1130
+ if cached is not None and cached[:2] == (s.st_mtime, s.st_size):
1131
+ return cached[2]
1132
+ try:
1133
+ # Lazy open parses just the header - cheap, no pixel decode.
1134
+ with Image.open(path):
1135
+ ok = True
1136
+ except Exception:
1137
+ ok = False
1138
+ cls._claims_cache[str(path)] = (s.st_mtime, s.st_size, ok)
1139
+ return ok
1140
+
1141
+ @staticmethod
1142
+ def load(address, **kwargs):
1143
+ path = address.path
1144
+ return ImageCodec.decode_bytes(path.read_bytes(), str(path))
1145
+
1146
+ @staticmethod
1147
+ def decode_bytes(raw, key):
1148
+ """PIL-decode `raw` into a PendingTexture registered under `key` —
1149
+ load()'s body, shared with the code editor's compare pane, which
1150
+ renders a git BLOB of the image (bytes that never exist on disk;
1151
+ the caller keys those per (path, reference) so they never clobber
1152
+ the live file's texture)."""
1153
+ image = Image.open(io.BytesIO(raw))
1154
+ if image.mode == "P":
1155
+ image = image.convert("RGBA" if "transparency" in image.info else "RGB")
1156
+ elif image.mode == "1":
1157
+ image = image.convert("L")
1158
+ elif image.mode not in PIL_TO_GL_FORMAT:
1159
+ image = image.convert("RGBA")
1160
+ image = image.transpose(Image.FLIP_TOP_BOTTOM)
1161
+
1162
+ width, height = image.size
1163
+ pending = PendingTexture(name=key, tex_width=width, tex_height=height,
1164
+ gl_format=PIL_TO_GL_FORMAT[image.mode],
1165
+ data=image.tobytes())
1166
+ # Registers with the manager (dedupes against an already-uploaded
1167
+ # texture for this path); the GL upload itself happens on the render
1168
+ # thread via the wrapper's pending_upload() hook.
1169
+ Core.melty.texture_manager.put_pending(key, pending)
1170
+ return pending
1171
+
1172
+ @staticmethod
1173
+ def save(*args, **kwargs):
1174
+ return False
1175
+
1176
+
1177
+ class BinaryFileCodec(Codec):
1178
+ """Read-only fallback for files no other codec claims (unknown extension /
1179
+ no extension, content sniffed as binary): show metadata and a short hex
1180
+ preview instead of "No codec". Reached via codec_for_path, never the
1181
+ extension registry. save() refuses — the summary is a VIEW of the bytes,
1182
+ and writing it back would replace the file with its own hexdump."""
1183
+
1184
+ name = "Binary File"
1185
+ # The summary is a str, so the caller's text view renders it - but it is
1186
+ # a VIEW of the bytes, never written back.
1187
+ editable = False
1188
+ resolve_address = staticmethod(_resolve_plain_file)
1189
+
1190
+ PREVIEW_BYTES = 256
1191
+
1192
+ @staticmethod
1193
+ def load(address, **kwargs):
1194
+ path = address.path
1195
+ size = path.stat().st_size
1196
+ kind, _ = mimetypes.guess_type(path.name)
1197
+ head = path.open("rb").read(BinaryFileCodec.PREVIEW_BYTES)
1198
+
1199
+ lines = [f"{path.name} — {size:,} bytes ({kind or 'unknown type'})", ""]
1200
+ for off in range(0, len(head), 16):
1201
+ row = head[off:off + 16]
1202
+ hx = " ".join(f"{b:02x}" for b in row)
1203
+ ascii_ = "".join(chr(b) if 32 <= b < 127 else "." for b in row)
1204
+ lines.append(f"{off:08x} {hx:<47} {ascii_}")
1205
+ if size > len(head):
1206
+ lines.append(f"… {size - len(head):,} more bytes")
1207
+ return "\n".join(lines)
1208
+
1209
+ @staticmethod
1210
+ def save(*args, **kwargs):
1211
+ return False
1212
+
1213
+
1214
+ def codec_for_path(path):
1215
+ """Every real file resolves to SOME codec: registered extension first,
1216
+ else sniff the head — NUL-free utf-8 edits as text (TextFileCodec),
1217
+ anything else gets the read-only binary summary. This is what lets the
1218
+ folder windows mount a stress-test directory full of extensionless blobs
1219
+ without a wall of "No codec" rows.
1220
+
1221
+ The extension match is subject to the codec's content veto (claims): a
1222
+ file whose bytes don't decode as the extension promises (an empty or
1223
+ corrupt .png) falls through to the sniff instead of being routed to a
1224
+ load() that can only throw."""
1225
+ if isinstance(path, str) and len(path) > 255:
1226
+ return None
1227
+
1228
+ codec = extension_to_codec.get(path.suffix.lower())
1229
+ if codec is not None and codec.claims(path):
1230
+ return codec
1231
+ if not path.is_file():
1232
+ return None
1233
+ try:
1234
+ head = path.open("rb").read(4096)
1235
+ except OSError:
1236
+ return None
1237
+ if b"\0" in head:
1238
+ return BinaryFileCodec
1239
+ try:
1240
+ head.decode("utf-8")
1241
+ except UnicodeDecodeError:
1242
+ # A multi-byte char split at the 4096 boundary technically lands here -
1243
+ # acceptable: the file still renders, just as the binary summary.
1244
+ return BinaryFileCodec
1245
+ return TextFileCodec
1246
+
1247
+
1248
+ def asset_extensions():
1249
+ """Registered extensions whose codec loads something OTHER than editor
1250
+ text — images today, a .npy codec tomorrow. This is the non-Python file
1251
+ set global search's Code tab lists beside the loaded modules: register
1252
+ a codec with `ext=` and its files become searchable/openable, nothing
1253
+ else to wire. Lowercase, dotted (".png"), as the registry stores them."""
1254
+ return {ext for ext, codec in extension_to_codec.items()
1255
+ if codec is not TextFileCodec}