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