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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- 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
|