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,1083 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RenderHost — a dict that drives a stateful wrapper and holds its editable value.
|
|
3
|
+
|
|
4
|
+
A `RenderHost` is just a `dict`. It is NOT a view function. Instead it WRAPS a
|
|
5
|
+
stateful func (`code_file_io`, `convert_in_and_out_value`) that follows the shape
|
|
6
|
+
|
|
7
|
+
stateful_work() → view_func(value) → stateful_work()
|
|
8
|
+
|
|
9
|
+
(load → edit → save for code_file_io; chain_in → edit → chain_out for convert). The
|
|
10
|
+
host hands that wrapper its OWN private view_func; whatever the wrapper passes in —
|
|
11
|
+
a source STRING from code_file_io, the parsed TREE from convert_in_and_out_value —
|
|
12
|
+
is materialized into the host's dict (held under `value_key`, type intact), rendered,
|
|
13
|
+
and any edit flows back out the same channel. So the host "looks like a dict with a
|
|
14
|
+
single value inside," and you use it like that value.
|
|
15
|
+
|
|
16
|
+
Chaining is plain data-flow through `input_value`: a proxy whose `input_value` is
|
|
17
|
+
another `RenderHost` reads that upstream proxy's held value ("used like a string").
|
|
18
|
+
Edits flow back upstream — `draw()` writes the wrapper's output onto the upstream
|
|
19
|
+
proxy, which goes dirty and saves one frame later. That one-frame lag is the only
|
|
20
|
+
cost of decoupling the proxies into independent `draw()` calls.
|
|
21
|
+
|
|
22
|
+
string_proxy = RenderHost(wrapper=code_file_io, input_value=InCode)
|
|
23
|
+
dict_proxy = RenderHost(wrapper=convert_in_and_out_value, input_value=string_proxy,
|
|
24
|
+
child_kwargs={chain_in, chain_out, route})
|
|
25
|
+
|
|
26
|
+
`draw_main` calls `draw()` on each registered host every frame (upstream first), so
|
|
27
|
+
each proxy renders into its own little Melty window; and because it's a plain dict,
|
|
28
|
+
`draw_collection(proxy)` renders it anywhere too. Nested edits bubble (see
|
|
29
|
+
bubbling.py) so a deep change to the held tree marks the host without a manual touch.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import sys
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
from meltygui.core.melty import Melty
|
|
37
|
+
from meltygui.core.diagnostics.notifications import notify
|
|
38
|
+
from meltygui.core.rendering.render_funcs import RenderFuncs
|
|
39
|
+
from meltygui.core.windowing.glfw_utils import request_render
|
|
40
|
+
from meltygui.core.windowing.glfw_utils import print_stack_trace
|
|
41
|
+
from meltygui.core.diagnostics.perf_trace import trace as _ptrace
|
|
42
|
+
from meltygui.core.diagnostics.perf_trace import trace_rl as _ptrace_rl
|
|
43
|
+
from meltygui.core.conversion.bubbling import install_bubbling
|
|
44
|
+
from meltygui.core.conversion.bubbling import _reinstall_children
|
|
45
|
+
from meltygui.core.conversion.bubbling import _DeepAttrMixin
|
|
46
|
+
from meltygui.core.core_render import render_func
|
|
47
|
+
from meltygui.core.rendering.core_decoration import defaults
|
|
48
|
+
from meltygui.core.rendering.core_decoration import Core
|
|
49
|
+
from meltygui.core.cache.invalidation_tracker import Note
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_UNSET = object()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@defaults()
|
|
56
|
+
class RenderHost(_DeepAttrMixin, dict):
|
|
57
|
+
"""A dict that drives a stateful `wrapper` and holds the value it edits.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
io_function: the stateful func to drive (code_file_io / convert_in_and_out_value).
|
|
61
|
+
`None` → a plain data-bag: `draw()` just renders the dict. This can contain UI but does not need to
|
|
62
|
+
Any UI drawn as part of this method will end up in a settings panel, not the main UI
|
|
63
|
+
input_value: what the wrapper operates on — a raw value (a class, a path), or
|
|
64
|
+
an upstream `RenderHost` (resolved to its held value each frame).
|
|
65
|
+
child_kwargs: extra kwargs for the wrapper (e.g. convert's chain_in/out + route).
|
|
66
|
+
settings_renderer: This is draw inside the settings panel, and entirely optional
|
|
67
|
+
|
|
68
|
+
(default: `draw_any`, which routes str → editor, dict → collection).
|
|
69
|
+
value_key: the dict key the held value lives under (type intact).
|
|
70
|
+
standalone: register with Melty so draw_main calls `draw()` (its own window)."""
|
|
71
|
+
|
|
72
|
+
# The host currently rendering, so render_host_view can find it without threading
|
|
73
|
+
# it through cache-hashed kwargs.
|
|
74
|
+
_active = []
|
|
75
|
+
|
|
76
|
+
# Log every change the proxy triggers (dict-dirty + outbound edits) with a caller
|
|
77
|
+
# trail - for diagnosing spurious saves. `RenderHost.debug_changes = False` silences.
|
|
78
|
+
debug_changes = False
|
|
79
|
+
|
|
80
|
+
# Class-level fallbacks for the consumer-notify debounce fields, so host
|
|
81
|
+
# instances born before a hotswap of this class still resolve them.
|
|
82
|
+
_local_edit_time = 0.0
|
|
83
|
+
_pending_notify_name = None
|
|
84
|
+
_notify_timer = None
|
|
85
|
+
|
|
86
|
+
def __init__(self, io_function=None, *args, input_value=None, child_kwargs=None,
|
|
87
|
+
settings_renderer=None, name=None, hidden=False, window=True, standalone=True,
|
|
88
|
+
value_key="value", evictable=False, **extra):
|
|
89
|
+
super().__init__(*args)
|
|
90
|
+
self.io_function = io_function
|
|
91
|
+
# `is None` (not falsy): an empty host-dict is a valid input_value.
|
|
92
|
+
self.input_value = self if input_value is None else input_value
|
|
93
|
+
# Wrapper options (chain_in/out, route, ...). `extra` kwargs fold in too, so you
|
|
94
|
+
# can pass them directly instead of nesting a child_kwargs dict.
|
|
95
|
+
self.child_kwargs = {**(child_kwargs or {}), **extra}
|
|
96
|
+
self.settings_renderer = settings_renderer
|
|
97
|
+
self.name = name or getattr(io_function, "__name__", None) or "RenderHost"
|
|
98
|
+
self.hidden = hidden
|
|
99
|
+
self.window = window
|
|
100
|
+
self.standalone = standalone
|
|
101
|
+
# evictable → the idle sweep (RenderHost.sweep) may DEREGISTER this host
|
|
102
|
+
# from Melty.render_hosts when ALL its consumer windows close - it stays
|
|
103
|
+
# in the code-host cache and re-registers on reopen (notify_on_change →
|
|
104
|
+
# register). Module-level singleton proxies (files_proxy, claude_proxy, the
|
|
105
|
+
# playground demos) default this False so they're never swept; only
|
|
106
|
+
# code_hosts_for opts its short-lived cache pairs in.
|
|
107
|
+
self.evictable = evictable
|
|
108
|
+
self._birth_frame = Melty.frame_count # idle-sweep birth grace
|
|
109
|
+
# The held value lives under this key, type intact - so a GeneralParse stays a
|
|
110
|
+
# GeneralParse for chain_out, and the proxy "looks like a dict with one value".
|
|
111
|
+
self.value_key = value_key
|
|
112
|
+
|
|
113
|
+
self._external_change = False # dict (or a nested value) mutated since last render
|
|
114
|
+
self._draw_state = None # render_host_view's draw_state (the window envelope)
|
|
115
|
+
self._wrapper_draw_state = None # the WRAPPER's draw_state (code_file_io / convert) via
|
|
116
|
+
# return_extras - so a value edit can invalidate IT and
|
|
117
|
+
# the wrapper actually re-runs to process the change.
|
|
118
|
+
self._last_resolved = _UNSET # last resolved input - drives the wrapper's external_change
|
|
119
|
+
self._pending_external = False # input changed (detected in draw()) → external_change for the wrapper
|
|
120
|
+
self._awaiting_inbound = False # an upstream edit happened; a remote derived result (e.g.
|
|
121
|
+
# convert's BACKGROUND chain_in) is in flight - materialize it
|
|
122
|
+
# when it lands, not just on the pulse frame.
|
|
123
|
+
# Frame stamps for event ordering. The held value (a LOCAL edit) is set
|
|
124
|
+
# immediately; the wrapper's derived result (a re-parse) is asynchronous and may be
|
|
125
|
+
# based on a source that lags the last local edit. We only accept an inbound
|
|
126
|
+
# result when the source it's based on is at least as NEW as the last local edit
|
|
127
|
+
# - otherwise the newest event (the local edit) wins. See _internal_view_func.
|
|
128
|
+
self._input_change_frame = 0 # frame the resolved INPUT (source) last changed
|
|
129
|
+
self._local_edit_frame = 0 # frame the held value was last LOCALLY edited
|
|
130
|
+
self._local_edit_time = 0.0 # wall clock of the last local edit - drives the
|
|
131
|
+
# consumer-notify debounce (typing_hold)
|
|
132
|
+
self._pending_notify_name = None # deferred _notify_consumers note name
|
|
133
|
+
self._notify_timer = None # armed flush timer for the deferred notify
|
|
134
|
+
self._last_return = None
|
|
135
|
+
self._registered = False
|
|
136
|
+
# Draw_states that READ this host's value from OUTSIDE its own draw loop (e.g.
|
|
137
|
+
# draw_input_tab renders host.deep.parameters() inside the context menu). The
|
|
138
|
+
# host parses on a background worker in its own main window; when the value
|
|
139
|
+
# finally materializes, request_render() wakes the loop but doesn't reach the
|
|
140
|
+
# external cached subtrees - so they'd show nothing until an unrelated manual
|
|
141
|
+
# invalidation. They register here and _materialize invalidates them on change.
|
|
142
|
+
# Maps each consumer draw_state → the frame it last re-registered
|
|
143
|
+
# (notify_on_change); the idle sweep reads that last-seen stamp (plus
|
|
144
|
+
# abs_closed) to decide whether this host still has an alive user.
|
|
145
|
+
self._consumers = {}
|
|
146
|
+
|
|
147
|
+
# Bubble nested changes: upgrade existing contents + a non-self input tree so
|
|
148
|
+
# a deep edit (held['cfg']['rank'] = 16, or a grabbed GeneralParse) marks the
|
|
149
|
+
# host without a manual touch(). See bubbling.py.
|
|
150
|
+
if len(self):
|
|
151
|
+
_reinstall_children(self, self)
|
|
152
|
+
if self.input_value is not self and not isinstance(self.input_value, RenderHost):
|
|
153
|
+
self.input_value = install_bubbling(self.input_value, self)
|
|
154
|
+
|
|
155
|
+
if standalone:
|
|
156
|
+
self.register()
|
|
157
|
+
|
|
158
|
+
# ── Registration ──────────────────────────────────────────────────────────
|
|
159
|
+
def register(self):
|
|
160
|
+
"""Add to Melty.render_hosts so draw_main calls draw() each frame. Idempotent.
|
|
161
|
+
|
|
162
|
+
Also re-registers the upstream proxy in the chain (a dict_host's str_host):
|
|
163
|
+
str_host has no consumers of its own, so reviving the dict_host after an idle
|
|
164
|
+
sweep must bring its source proxy back too, or the value goes stale. Guarded on
|
|
165
|
+
the upstream's own _registered flag, so a chain can't recurse forever."""
|
|
166
|
+
if not self._registered:
|
|
167
|
+
Melty.render_hosts[id(self)] = self
|
|
168
|
+
self._registered = True
|
|
169
|
+
|
|
170
|
+
iv = self.input_value
|
|
171
|
+
if isinstance(iv, RenderHost) and iv is not self and not iv._registered:
|
|
172
|
+
iv.register()
|
|
173
|
+
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def notify_on_change(self, draw_state):
|
|
177
|
+
"""Register an EXTERNAL consumer's draw_state to be invalidated when this host's
|
|
178
|
+
held value next materializes/changes. Idempotent. For code that reads the host's
|
|
179
|
+
value (e.g. host.deep.parameters()) and draws it OUTSIDE the host's own draw loop
|
|
180
|
+
— without this its cached subtree never re-runs when a background parse lands.
|
|
181
|
+
|
|
182
|
+
Doubles as the per-frame "I am a live user" pulse the idle sweep reads: call it
|
|
183
|
+
every frame the consumer draws (not just on edit), so its last-seen stamp stays
|
|
184
|
+
current. Reviving a host the sweep deregistered happens here too — reopening a
|
|
185
|
+
closed view re-registers it (and its upstream proxy chain) in Melty.render_hosts."""
|
|
186
|
+
if draw_state is None:
|
|
187
|
+
return
|
|
188
|
+
self._consumers[draw_state] = Melty.frame_count
|
|
189
|
+
# Reopened view → the idle sweep had popped us from render_hosts; bring the
|
|
190
|
+
# host (and its str_host) back into the draw loop. Idempotent.
|
|
191
|
+
if self.standalone and not self._registered:
|
|
192
|
+
self.register()
|
|
193
|
+
# Toggle-off fallback: with no sweep pruning each frame, cap growth by
|
|
194
|
+
# dropping closed consumers when the map gets large.
|
|
195
|
+
if len(self._consumers) > 64:
|
|
196
|
+
self._consumers = {ds: f for ds, f in self._consumers.items()
|
|
197
|
+
if not getattr(ds, 'closed', False)}
|
|
198
|
+
|
|
199
|
+
def remove(self):
|
|
200
|
+
Melty.render_hosts.pop(id(self), None)
|
|
201
|
+
self._registered = False
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
# One outstanding catch-up timer for the typing hold (class-wide - the hold
|
|
205
|
+
# gates ALL hosts at once in draw_main's loop).
|
|
206
|
+
_typing_wake_timer = None
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def typing_hold(cls):
|
|
210
|
+
"""True while host draws should be deferred because the user is typing:
|
|
211
|
+
a draw_text editor holds focus AND a key was pressed within the debounce
|
|
212
|
+
window. The draw_main host loop checks this next to its click/drag/
|
|
213
|
+
scroll skip — host draws (reconverts, saves) are deferrable work that
|
|
214
|
+
would otherwise eat into typing frames. Arms a one-shot wake for the
|
|
215
|
+
window's expiry so the skipped draws catch up even when no further
|
|
216
|
+
input produces a frame; a newer keypress simply re-enters the hold on
|
|
217
|
+
that wake's frame and chains the next one."""
|
|
218
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
219
|
+
window = Toggles.HostLifecycle.host_typing_debounce_ms / 1000.0
|
|
220
|
+
if window <= 0 or Melty.text_focused_ds is None:
|
|
221
|
+
return False
|
|
222
|
+
# A physically HELD key counts as pressed regardless of the stamp -
|
|
223
|
+
# repeats don't reliably re-stamp it (Wayland repeat gaps exceed the OS
|
|
224
|
+
# repeat delay), so the hold would otherwise expire mid-press. Reconcile
|
|
225
|
+
# against glfw.get_key first: a RELEASE that went elsewhere (focus
|
|
226
|
+
# stolen mid-hold) would strand the key in the set and starve host
|
|
227
|
+
# draws forever.
|
|
228
|
+
held = bool(Melty._keys_down)
|
|
229
|
+
if held and Melty.glfw_window is not None:
|
|
230
|
+
import meltygui.core.windowing.window_api as glfw
|
|
231
|
+
for k in list(Melty._keys_down):
|
|
232
|
+
try:
|
|
233
|
+
if glfw.get_key(Melty.glfw_window, k) != glfw.PRESS:
|
|
234
|
+
Melty._keys_down.discard(k)
|
|
235
|
+
except Exception:
|
|
236
|
+
Melty._keys_down.discard(k)
|
|
237
|
+
held = bool(Melty._keys_down)
|
|
238
|
+
remaining = Melty._last_key_time + window - time.monotonic()
|
|
239
|
+
if not held and remaining <= 0:
|
|
240
|
+
return False
|
|
241
|
+
# Catch-up wake: for a debounce tail, at its expiry; while a key is
|
|
242
|
+
# held past the stamp window, a full window out - RELEASE re-stamps
|
|
243
|
+
# and forces a frame anyway, the hold is just the safety net.
|
|
244
|
+
timer = cls._typing_wake_timer
|
|
245
|
+
if timer is None or not timer.is_alive():
|
|
246
|
+
timer = threading.Timer(remaining if remaining > 0 else window,
|
|
247
|
+
cls._typing_wake)
|
|
248
|
+
timer.daemon = True
|
|
249
|
+
cls._typing_wake_timer = timer
|
|
250
|
+
timer.start()
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def _typing_wake(cls):
|
|
255
|
+
cls._typing_wake_timer = None
|
|
256
|
+
request_render()
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def all(cls):
|
|
260
|
+
return list(Melty.render_hosts.values())
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def draw_all(cls, mark=None):
|
|
264
|
+
"""The per-frame host pump: draw every registered host that has work.
|
|
265
|
+
Hosts are self-registering (a code_file_io host loads its file on its
|
|
266
|
+
first draw, reparses after an edit, auto-saves), so whatever runs the
|
|
267
|
+
frame loop must call this once a frame — the studio from draw_main,
|
|
268
|
+
a meltygui app from Surface.frame — or nothing ever loads.
|
|
269
|
+
|
|
270
|
+
Skipped entirely while any mouse button is held (click or drag) or a
|
|
271
|
+
view is being scrolled (Melty.on_scroll lingers a few frames past the
|
|
272
|
+
last wheel event, bridging a trackpad's event stream): host draws are
|
|
273
|
+
deferrable work that would otherwise eat into drag/scroll frames;
|
|
274
|
+
they catch up after. The typing hold (keypress debounce, self-armed
|
|
275
|
+
catch-up wake) defers only HIDDEN CACHE hosts (##-named evictable
|
|
276
|
+
pairs — their reconverts/saves are the GIL-heavy work that eats
|
|
277
|
+
typing frames); VISIBLE hosts keep drawing through it, since a
|
|
278
|
+
params field's keystrokes publish fresh tensors into the value
|
|
279
|
+
windows and holding those froze every visualization for the burst.
|
|
280
|
+
|
|
281
|
+
Event-driven: an idle hidden cache host skips its draw entirely
|
|
282
|
+
(draw_needed — edit flags / upstream identity change / tile
|
|
283
|
+
invalidation / slow heartbeat). An open input tab registers ~20
|
|
284
|
+
code-cache pairs and each polled draw is ~0.1ms of wrapper overhead
|
|
285
|
+
doing nothing — that is what dropped drags to 70fps. ``mark(label)``,
|
|
286
|
+
if given, receives a perf-trace label per gate decision and host drawn."""
|
|
287
|
+
import meltygui_imgui as imgui
|
|
288
|
+
from meltygui.core.windowing.surface import Surface
|
|
289
|
+
import meltygui.core.windowing.window_api as glfw
|
|
290
|
+
any_mouse_held = (imgui.is_mouse_down(0) or imgui.is_mouse_down(1)
|
|
291
|
+
or imgui.is_mouse_down(2) or Melty.space_mouse_drag)
|
|
292
|
+
# The host pump is process-wide and runs on the first surface. A
|
|
293
|
+
# picker drag belongs to the child's input context, so the parent's
|
|
294
|
+
# ImGui state alone misses it and runs deferred hosts throughout
|
|
295
|
+
# the drag. Read the windows' button state without changing contexts.
|
|
296
|
+
any_mouse_held = any_mouse_held or any(
|
|
297
|
+
glfw.get_mouse_button(surface.window, button) == glfw.PRESS
|
|
298
|
+
for surface in Surface.all if surface.window is not None and not surface.closed
|
|
299
|
+
for button in (glfw.MOUSE_BUTTON_LEFT, glfw.MOUSE_BUTTON_RIGHT, glfw.MOUSE_BUTTON_MIDDLE))
|
|
300
|
+
typing_held = cls.typing_hold()
|
|
301
|
+
if mark is not None:
|
|
302
|
+
mark(f"hosts_gate(held={int(any_mouse_held)},typing={int(bool(typing_held))})")
|
|
303
|
+
if any_mouse_held or Melty.on_scroll:
|
|
304
|
+
return
|
|
305
|
+
# Snapshot: a host may register / remove during its draw.
|
|
306
|
+
for host in cls.all():
|
|
307
|
+
if (typing_held and getattr(host, "evictable", False)
|
|
308
|
+
and host.name.startswith("##")):
|
|
309
|
+
continue
|
|
310
|
+
if not host.draw_needed():
|
|
311
|
+
continue
|
|
312
|
+
host.draw()
|
|
313
|
+
if mark is not None:
|
|
314
|
+
mark(f"host[{str(host.name)[:24]}]")
|
|
315
|
+
# Drawing alone is not a change: cached or idle draws must let the
|
|
316
|
+
# loop sleep. Materialization, edits and background completions already
|
|
317
|
+
# request their own frames. A missing parse is not pending work either
|
|
318
|
+
# (plain data / cache hosts may never have a value_key entry).
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def current(cls):
|
|
322
|
+
return cls._active[-1] if cls._active else None
|
|
323
|
+
|
|
324
|
+
# ── Idle sweep: deregister hosts whose consumer windows have all closed ─────
|
|
325
|
+
@staticmethod
|
|
326
|
+
def _consumer_closed(ds):
|
|
327
|
+
"""A consumer draw_state counts as gone once its window is abs_closed —
|
|
328
|
+
the primary, immediate trigger. A vanished/broken draw_state (raises) is
|
|
329
|
+
treated as gone too."""
|
|
330
|
+
try:
|
|
331
|
+
return bool(ds.abs_closed)
|
|
332
|
+
except Exception:
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
def _prune_consumers(self, now, k):
|
|
336
|
+
"""Drop consumers that are abs_closed OR whose k-frame liveness net
|
|
337
|
+
tripped. The net keys on the LATER of two stamps: registration
|
|
338
|
+
(notify_on_change, body actually ran) and ds.last_seen (the blit
|
|
339
|
+
compositor stamps it every frame the tile is drawn, even when the body
|
|
340
|
+
cache-skips). A blit-cached view in an OPEN window stops registering
|
|
341
|
+
for minutes but keeps compositing, so it survives; a closed dropdown /
|
|
342
|
+
context-menu popover stops BOTH (and is never abs_closed — popovers
|
|
343
|
+
don't set closed), so it ages out instead of pinning the host in
|
|
344
|
+
Melty.render_hosts forever. Registration-only aging (the pre-2026-07
|
|
345
|
+
net) pruned the blit-cached case too — host swept, external file
|
|
346
|
+
change had no pump and no consumer, view froze on stale values.
|
|
347
|
+
|
|
348
|
+
The net's window is deliberately wider than k: last_seen lags while
|
|
349
|
+
an ANCESTOR's tile covers the consumer (only the topmost blitted tile
|
|
350
|
+
gets stamped — measured lags of a few hundred frames on live views),
|
|
351
|
+
so the raw k=120 would prune covered-but-live consumers and re-open
|
|
352
|
+
the frozen-view hole. 10·k keeps those safe with ~5x margin while a
|
|
353
|
+
popover orphan (measured 1000+ frames stale on BOTH stamps) still
|
|
354
|
+
ages out and lets its host sweep."""
|
|
355
|
+
k = 10 * k
|
|
356
|
+
cons = self._consumers
|
|
357
|
+
if not isinstance(cons, dict): # pre-hotswap list shape - reset
|
|
358
|
+
self._consumers = {}
|
|
359
|
+
return
|
|
360
|
+
self._consumers = {ds: seen for ds, seen in cons.items()
|
|
361
|
+
if not self._consumer_closed(ds)
|
|
362
|
+
and (now - max(seen, getattr(ds, 'last_seen', None) or 0)) <= k}
|
|
363
|
+
|
|
364
|
+
@classmethod
|
|
365
|
+
def sweep(cls):
|
|
366
|
+
"""Deregister evictable hosts with no active consumer from Melty.render_hosts
|
|
367
|
+
so draw_main stops drawing them each frame. The host and its parse stay in the
|
|
368
|
+
code-host cache (NOT evicted) — reopening the view re-registers it via
|
|
369
|
+
notify_on_change → register. Toggle-gated; runs once per frame from end_frame."""
|
|
370
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
371
|
+
if not Toggles.HostLifecycle.deregister_idle:
|
|
372
|
+
return
|
|
373
|
+
k = int(Toggles.HostLifecycle.idle_frames)
|
|
374
|
+
now = Melty.frame_count
|
|
375
|
+
hosts = list(Melty.render_hosts.values())
|
|
376
|
+
|
|
377
|
+
# First pass: direct liveness. Non-evictable singletons are always alive;
|
|
378
|
+
# a freshly-born host is held through a birth grace (its consumer may not
|
|
379
|
+
# have drawn yet - Mode.WINDOW bodies render deferred).
|
|
380
|
+
alive = {}
|
|
381
|
+
for h in hosts:
|
|
382
|
+
if not getattr(h, "evictable", False):
|
|
383
|
+
alive[id(h)] = True
|
|
384
|
+
continue
|
|
385
|
+
if now - getattr(h, "_birth_frame", 0) <= k:
|
|
386
|
+
alive[id(h)] = True
|
|
387
|
+
continue
|
|
388
|
+
h._prune_consumers(now, k)
|
|
389
|
+
alive[id(h)] = bool(h._consumers)
|
|
390
|
+
|
|
391
|
+
# Second pass: a host referenced as a live host's input_value stays alive.
|
|
392
|
+
# The str_host behind a dict_host has no consumers of its own, so it would
|
|
393
|
+
# otherwise be swept out from under the dict_host that still needs it.
|
|
394
|
+
changed = True
|
|
395
|
+
while changed:
|
|
396
|
+
changed = False
|
|
397
|
+
for h in hosts:
|
|
398
|
+
if not alive.get(id(h)):
|
|
399
|
+
continue
|
|
400
|
+
iv = getattr(h, "input_value", None)
|
|
401
|
+
if isinstance(iv, RenderHost) and iv is not h and not alive.get(id(iv)):
|
|
402
|
+
alive[id(iv)] = True
|
|
403
|
+
changed = True
|
|
404
|
+
|
|
405
|
+
for h in hosts:
|
|
406
|
+
if getattr(h, "evictable", False) and not alive.get(id(h)):
|
|
407
|
+
h.remove() # pop from render_hosts but KEEP the cache entry
|
|
408
|
+
|
|
409
|
+
# ── Visibility ────────────────────────────────────────────────────────────
|
|
410
|
+
def show(self):
|
|
411
|
+
self.hidden = False
|
|
412
|
+
request_render()
|
|
413
|
+
return self
|
|
414
|
+
|
|
415
|
+
def hide(self):
|
|
416
|
+
self.hidden = True
|
|
417
|
+
request_render()
|
|
418
|
+
return self
|
|
419
|
+
|
|
420
|
+
def toggle(self):
|
|
421
|
+
self.hidden = not self.hidden
|
|
422
|
+
request_render()
|
|
423
|
+
return self
|
|
424
|
+
|
|
425
|
+
# ── Change logging (diagnose spurious saves) ──────────────────────────────
|
|
426
|
+
def _caller_trail(self, frames=4, skip=2):
|
|
427
|
+
out = []
|
|
428
|
+
try:
|
|
429
|
+
f = sys._getframe(skip)
|
|
430
|
+
for _ in range(frames):
|
|
431
|
+
if f is None:
|
|
432
|
+
break
|
|
433
|
+
base = f.f_code.co_filename.rsplit("/", 1)[-1]
|
|
434
|
+
out.append(f"{base}:{f.f_lineno} {f.f_code.co_name}")
|
|
435
|
+
f = f.f_back
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
return " <- ".join(out)
|
|
439
|
+
|
|
440
|
+
def _log_change(self, event, **info):
|
|
441
|
+
if not RenderHost.debug_changes:
|
|
442
|
+
return
|
|
443
|
+
extra = " ".join(f"{k}={v}" for k, v in info.items())
|
|
444
|
+
print(f"[RenderHost:{self.name}] {event} {extra} ║ {self._caller_trail()}")
|
|
445
|
+
|
|
446
|
+
# ── Dict mutation → external_change (bubble root) ──────────────────────────
|
|
447
|
+
def _mark_changed(self):
|
|
448
|
+
self._external_change = True
|
|
449
|
+
self._local_edit_frame = Melty.frame_count # stamp: a LOCAL edit happened NOW
|
|
450
|
+
self._local_edit_time = time.monotonic() # re-arms the consumer-notify debounce
|
|
451
|
+
# Debug timeline: who dirtied this host (a symbol-attach bubbling into the
|
|
452
|
+
# held gp would show up here as _distribute_by_name / _post_symbol_attach).
|
|
453
|
+
_ptrace_rl(("host-dirty", self.name), f"host DIRTY {self.name} <- {self._caller_trail(frames=5)}",
|
|
454
|
+
min_interval=0.05)
|
|
455
|
+
# Invalidate BOTH the window envelope AND the wrapper's own draw_state: the
|
|
456
|
+
# envelope so render_host_view re-runs and calls the wrapper, and the wrapper
|
|
457
|
+
# so its blit-cached body actually re-executes to process the edit (load/save
|
|
458
|
+
# or chain_out). Invalidating only the envelope leaves the wrapper replayed.
|
|
459
|
+
for ds in (self._draw_state, self._wrapper_draw_state):
|
|
460
|
+
if ds is not None:
|
|
461
|
+
ds._external_change = True
|
|
462
|
+
try:
|
|
463
|
+
pass
|
|
464
|
+
# ds.invalidate(frame_delta=0)
|
|
465
|
+
# ds.invalidate(frame_delta=1)
|
|
466
|
+
note = Note(name="RenderHost _mark_changed", tint=(1, 0.5, 0), draw_state=ds)
|
|
467
|
+
ds.invalidate(note=note)
|
|
468
|
+
notify("invalidate #1", tag="host", tint=(1,0,1))
|
|
469
|
+
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
self._log_change("DIRTY (dict/nested mutated)")
|
|
473
|
+
request_render()
|
|
474
|
+
|
|
475
|
+
def touch(self):
|
|
476
|
+
"""Manually flag a change. Rarely needed — nested dict/list/GeneralParse edits
|
|
477
|
+
bubble automatically (bubbling.py). Use for an ATTRIBUTE write on a nested
|
|
478
|
+
non-container, or an in-place edit of an opaque leaf. Returns self."""
|
|
479
|
+
self._mark_changed()
|
|
480
|
+
return self
|
|
481
|
+
|
|
482
|
+
# NOTE: deep path traversal is the EXPLICIT `.deep` property (from _DeepAttrMixin),
|
|
483
|
+
# never a blanket __getattr__. A blanket __getattr__ would answer the framework's
|
|
484
|
+
# bare attribute probes on the host with a _DeepPath stand-in instead of raising
|
|
485
|
+
# AttributeError, and rendering (which expects a _BubblingDict) chokes on it. Bare
|
|
486
|
+
# `host.foo` must behave like a normal dict/object; use `host.deep.foo...()` to
|
|
487
|
+
# traverse.
|
|
488
|
+
|
|
489
|
+
def __setitem__(self, key, value):
|
|
490
|
+
# Re-assigning an equal value isn't an edit (draw_collection writes its
|
|
491
|
+
# rendered value back each frame) - store it but don't mark dirty / redraw.
|
|
492
|
+
unchanged = key in self and dict.__getitem__(self, key) is value
|
|
493
|
+
|
|
494
|
+
value = install_bubbling(value, self)
|
|
495
|
+
super().__setitem__(key, value)
|
|
496
|
+
if not unchanged:
|
|
497
|
+
self._mark_changed()
|
|
498
|
+
|
|
499
|
+
def __delitem__(self, key):
|
|
500
|
+
super().__delitem__(key)
|
|
501
|
+
self._mark_changed()
|
|
502
|
+
|
|
503
|
+
def update(self, *args, **kwargs):
|
|
504
|
+
super().update(*args, **kwargs)
|
|
505
|
+
_reinstall_children(self, self)
|
|
506
|
+
self._mark_changed()
|
|
507
|
+
|
|
508
|
+
def setdefault(self, key, default=None):
|
|
509
|
+
existed = key in self
|
|
510
|
+
if not existed:
|
|
511
|
+
default = install_bubbling(default, self)
|
|
512
|
+
result = super().setdefault(key, default)
|
|
513
|
+
if not existed:
|
|
514
|
+
self._mark_changed()
|
|
515
|
+
return result
|
|
516
|
+
|
|
517
|
+
def pop(self, *args):
|
|
518
|
+
had = bool(self) and (not args or args[0] in self)
|
|
519
|
+
result = super().pop(*args)
|
|
520
|
+
if had:
|
|
521
|
+
self._mark_changed()
|
|
522
|
+
return result
|
|
523
|
+
|
|
524
|
+
def popitem(self):
|
|
525
|
+
result = super().popitem()
|
|
526
|
+
self._mark_changed()
|
|
527
|
+
return result
|
|
528
|
+
|
|
529
|
+
def clear(self):
|
|
530
|
+
if self:
|
|
531
|
+
super().clear()
|
|
532
|
+
self._mark_changed()
|
|
533
|
+
else:
|
|
534
|
+
super().clear()
|
|
535
|
+
|
|
536
|
+
# ── The host's private view_func (handed to its wrapper) ───────────────────
|
|
537
|
+
def _internal_view_func(self, input_value=None, external_change=False, **kwargs):
|
|
538
|
+
"""The view_func the host hands its wrapper. The wrapper passes the value to
|
|
539
|
+
edit — a source STRING (code_file_io) or the parsed TREE (convert_in_and_out_
|
|
540
|
+
value) — and we:
|
|
541
|
+
INBOUND : materialize it into our dict (held under value_key), unless we're
|
|
542
|
+
mid-edit (dirty) — never clobber a change flowing the other way.
|
|
543
|
+
RENDER : draw the held value (str → editor, dict → collection). A dict/tree
|
|
544
|
+
edit mutates in place and bubbles (→ dirty); a string edit returns.
|
|
545
|
+
OUTBOUND: if anything changed, return (True, held) so the wrapper saves /
|
|
546
|
+
chain_outs it. Returns the held value in the wrapper's native shape."""
|
|
547
|
+
pre_dirty = self._external_change
|
|
548
|
+
self._external_change = False
|
|
549
|
+
|
|
550
|
+
# convert_in_and_out_value tags each finished chain_in with the GENERATION (the
|
|
551
|
+
# origin local-edit frame) of the source it parsed - the echo of our own edit
|
|
552
|
+
# carries that edit's frame, a genuine external change carries "now". Use it as
|
|
553
|
+
# the frame-precedence baseline below (not the frame the parse happens to LAND),
|
|
554
|
+
# so a re-parse of an OLDER edit lands strictly before a newer local edit and
|
|
555
|
+
# can't clobber it - that race is the value-flicker. None on non-finish frames and
|
|
556
|
+
# for the code_file_io path (which doesn't pass it). Pop so it never reaches the
|
|
557
|
+
# renderer's kwargs.
|
|
558
|
+
inbound_gen = kwargs.pop("inbound_gen", None)
|
|
559
|
+
if inbound_gen is not None:
|
|
560
|
+
self._input_change_frame = inbound_gen
|
|
561
|
+
|
|
562
|
+
# INBOUND. The HELD value is AUTHORITATIVE: it was set via __setitem__ (the
|
|
563
|
+
# user's draw_text string) and flows OUT to the wrapper. We do NOT materialize
|
|
564
|
+
# the wrapper's value (code_file_io's text_cache, which LAGS the latest
|
|
565
|
+
# keystrokes and is a stale-to-save snapshot) back over it - that's the "save
|
|
566
|
+
# replaces the text with the saved version, losing chars typed during edit" bug.
|
|
567
|
+
#
|
|
568
|
+
# Two exceptions:
|
|
569
|
+
# - a genuine UPSTREAM change - draw() saw a NEW resolved input object and set
|
|
570
|
+
# `_pending_external` (NOT the wrapper's `external_change` param, which for
|
|
571
|
+
# code_file_io is just its per-keystroke reconvert pulse) - wins even over a
|
|
572
|
+
# local dirty; drop the stale dirty so we don't surface a spurious outbound.
|
|
573
|
+
# - an INITIAL fill, when nothing is held yet.
|
|
574
|
+
# An upstream change (draw() set `_pending_external`) means the wrapper will
|
|
575
|
+
# produce a FRESH derived result - but convert's chain_in re-parses on a
|
|
576
|
+
# BACKGROUND thread, so it fires a frame+ after the pulse. Latch "awaiting" so we
|
|
577
|
+
# still materialize it when it arrives (a new object), not just on the pulse
|
|
578
|
+
# frame - otherwise the gate blocks the catch-up and the dict holds the PREVIOUS
|
|
579
|
+
# parse forever ("dict always holds the old snapshot").
|
|
580
|
+
if self._pending_external:
|
|
581
|
+
self._awaiting_inbound = True
|
|
582
|
+
|
|
583
|
+
# A fresh DERIVED-result pulse must reach external consumers even when
|
|
584
|
+
# the VALUE didn't change: a FAILED parse keeps last_good held (so
|
|
585
|
+
# _materialize never runs) and parks the error in the wrapper's
|
|
586
|
+
# ModesState - consumers that read that error (the folder-tree editor's
|
|
587
|
+
# red-line highlight, via draw_text_from_code_cache) would otherwise
|
|
588
|
+
# never re-run their cached subtrees to show or clear it.
|
|
589
|
+
if external_change and self._consumers:
|
|
590
|
+
self._notify_consumers(name="RenderHost result pulse")
|
|
591
|
+
|
|
592
|
+
# FILE-WATCHER / external reload. The codec registers a file watcher on the
|
|
593
|
+
# host's draw_state at resolve_address; when the file changes, FileWatch sets
|
|
594
|
+
# that draw_state dirty and code_file_io re-reads the file into text_cache. draw()
|
|
595
|
+
# CAN'T see this - code_file_io's input object (a class / path) is immutable, so
|
|
596
|
+
# _pending_external never fires for it. Detect it here instead: the wrapper passed
|
|
597
|
+
# us a value we did NOT produce (an external_change pulse whose input_value is a
|
|
598
|
+
# DIFFERENT object than our held one) while we're not mid-edit. A keystroke does
|
|
599
|
+
# NOT match - after we surface, text_cache IS our held object, so `is not held` is
|
|
600
|
+
# false. Treat it like an upstream change (stamp input_change so frame precedence
|
|
601
|
+
# treats the source as fresh, arm awaiting); the pull below then materializes it.
|
|
602
|
+
if (external_change and input_value is not None and input_value is not self
|
|
603
|
+
and input_value is not self._held() and not pre_dirty):
|
|
604
|
+
# Keep the inbound generation when convert provided one (above); only the
|
|
605
|
+
# code_file_io path (inbound_gen=None) falls back to "now".
|
|
606
|
+
if inbound_gen is None:
|
|
607
|
+
self._input_change_frame = Melty.frame_count
|
|
608
|
+
self._awaiting_inbound = True
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# The file changed on disk - the wrapper/blit cache won't know to re-render
|
|
612
|
+
# the reloaded value, so invalidate this proxy's view explicitly (same path
|
|
613
|
+
# as the initial fill below).
|
|
614
|
+
if self._draw_state is not None and self._draw_state._parent is not None:
|
|
615
|
+
self._draw_state._parent.invalidate_up_by_obj(
|
|
616
|
+
obj=self, note=Note(name="file_reload", tint=(1, 0.6, 0.1)), max_depth=3)
|
|
617
|
+
request_render()
|
|
618
|
+
|
|
619
|
+
# FRAME PRECEDENCE (all O(1) — no content comparison). Is there a GENUINE pending
|
|
620
|
+
# local edit, newer than the source this inbound represents? `_input_change_frame`
|
|
621
|
+
# is the source's ORIGIN generation: for convert it's the edit-frame the parse
|
|
622
|
+
# round-trips from (tagged by convert_in_and_out_value, NOT the frame the parse
|
|
623
|
+
# landed - that distinction is what stops the flicker), and for code_file_io it's
|
|
624
|
+
# the frame its external source changed. Rendering the held value writes
|
|
625
|
+
# reconstructed children back (draw_collection) a frame later - a benign 1-frame
|
|
626
|
+
# "edit" echo - so a diff of ≤1 is NOT genuine; only a local edit MORE than 1
|
|
627
|
+
# frame ahead of what the inbound represents counts, and it WINS (inbound rejected).
|
|
628
|
+
local_ahead = self._local_edit_frame > self._input_change_frame + 1
|
|
629
|
+
|
|
630
|
+
if input_value is not None and input_value is not self:
|
|
631
|
+
if self.value_key not in self:
|
|
632
|
+
self._materialize(input_value)
|
|
633
|
+
note = Note(name="_materialize", tint=(1, 1.0, 1.0))
|
|
634
|
+
# Needed for initial load
|
|
635
|
+
self._draw_state._parent.invalidate_up_by_obj(obj=self, note=note, max_depth=3)
|
|
636
|
+
self._draw_state._parent.invalidate_up(note=note, max_depth=7)
|
|
637
|
+
|
|
638
|
+
request_render()
|
|
639
|
+
|
|
640
|
+
# initial fill
|
|
641
|
+
elif (self._awaiting_inbound and external_change
|
|
642
|
+
and input_value is not self._held()
|
|
643
|
+
and not local_ahead):
|
|
644
|
+
|
|
645
|
+
# Pull only the fresh parse. `external_change` is the wrapper signalling a
|
|
646
|
+
# FRESH derived result THIS frame (convert sets it only on the frame its
|
|
647
|
+
# background chain_in FINISHES, with the current source's parse - and
|
|
648
|
+
# run_in_background coalesces to latest-only, so a finished result is never
|
|
649
|
+
# superseded). Without this gate we'd pull whatever `primary` happens to be
|
|
650
|
+
# when `local_ahead` flips false - which, mid round-trip, is the STALE
|
|
651
|
+
# last-good parse of the PREVIOUS source → the 428↔445 snap-back/oscillation.
|
|
652
|
+
# `not local_ahead` still lets a genuine live edit win.
|
|
653
|
+
# Needed
|
|
654
|
+
note = Note(name="Render host, self", tint=(1, 1, 1))
|
|
655
|
+
self._draw_state._parent.invalidate_by_obj(obj=self, note=note)
|
|
656
|
+
self._materialize(input_value)
|
|
657
|
+
self._awaiting_inbound = False
|
|
658
|
+
|
|
659
|
+
if self.value_key not in self:
|
|
660
|
+
# Nothing held yet (pre-materialize): the renderer draws a blank
|
|
661
|
+
# where the content will be - stamp the placeholder frame so the
|
|
662
|
+
# wrapper commit doesn't restore persisted content heights over it
|
|
663
|
+
# (see Melty.pending_placeholder_frame).
|
|
664
|
+
Melty.pending_placeholder_frame = Melty.frame_count
|
|
665
|
+
renderer = self.settings_renderer or RenderFuncs.draw_blank
|
|
666
|
+
render_kwargs = {k: v for k, v in kwargs.items() if k != "routed"}
|
|
667
|
+
render_kwargs.setdefault("name", f"{self.name}##held")
|
|
668
|
+
result = renderer(self._held(), **render_kwargs)
|
|
669
|
+
r_changed, r_new = (result[0], result[1]) if isinstance(result, tuple) and len(result) >= 2 else (False, result)
|
|
670
|
+
# A string edit (immutable) comes back via the return; store it. A dict/tree
|
|
671
|
+
# edit mutated in place and already set _external_change via bubbling.
|
|
672
|
+
if r_changed and self.get(self.value_key) is not r_new:
|
|
673
|
+
# Needed
|
|
674
|
+
self[self.value_key] = r_new
|
|
675
|
+
self._draw_state._parent.invalidate_by_obj(obj=input_value)
|
|
676
|
+
self._draw_state._parent.invalidate_by_obj(obj=self)
|
|
677
|
+
request_render()
|
|
678
|
+
|
|
679
|
+
# OUTBOUND only a GENUINE local edit - one MORE than 1 frame ahead of the source
|
|
680
|
+
# (a real drag), NOT the 1-frame draw_collection frame echo. Without this, the
|
|
681
|
+
# echo re-surfaces the just-materialized value every cycle → chain_out → save in
|
|
682
|
+
# a self-sustaining loop. r_changed (the renderer's own edit) always surfaces.
|
|
683
|
+
if self._external_change:
|
|
684
|
+
self._wrapper_draw_state._parent.invalidate()
|
|
685
|
+
self._draw_state._parent.invalidate_by_obj(obj=input_value)
|
|
686
|
+
self._draw_state._parent.invalidate_by_obj(obj=self)
|
|
687
|
+
notify("invalidate #5", tag="host", tint=(1, 0, 1))
|
|
688
|
+
|
|
689
|
+
request_render()
|
|
690
|
+
|
|
691
|
+
_ext = self._external_change
|
|
692
|
+
edited = bool((pre_dirty and local_ahead) or _ext or r_changed)
|
|
693
|
+
self._external_change = False
|
|
694
|
+
if edited:
|
|
695
|
+
# Change timeline: WHY this host reports an edit to its wrapper - for the
|
|
696
|
+
# convert (dict) host this is exactly what starts a chain_out.
|
|
697
|
+
_ptrace(f"host OUTBOUND edited {self.name}",
|
|
698
|
+
pre_dirty=pre_dirty, local_ahead=local_ahead,
|
|
699
|
+
ext=_ext, r_changed=r_changed)
|
|
700
|
+
# Needed
|
|
701
|
+
note = Note(name="Render host, nested view edited", tint=(1, 1, 1))
|
|
702
|
+
# self._draw_state._parent.invalidate_by_obj(obj=input_value, note=note)
|
|
703
|
+
# notify("invalidate #6", tag="host", tint=(1, 0, 1))
|
|
704
|
+
|
|
705
|
+
request_render()
|
|
706
|
+
note = Note(name="Render host, self", tint=(1, 1, 1))
|
|
707
|
+
# self._draw_state._parent.invalidate_by_obj(obj=self, note=note)
|
|
708
|
+
# notify("invalidate #7", tag="host", tint=(1, 0, 1))
|
|
709
|
+
self._log_change("OUTBOUND → return (True, value)",
|
|
710
|
+
pre_dirty=pre_dirty, r_changed=r_changed, local_ahead=local_ahead)
|
|
711
|
+
return True, self._outbound_value()
|
|
712
|
+
return False, self._outbound_value()
|
|
713
|
+
|
|
714
|
+
# ── Materialize / surface ──────────────────────────────────────────────────
|
|
715
|
+
def _materialize(self, value):
|
|
716
|
+
"""Hold an INBOUND value under value_key (type intact, bubbling installed).
|
|
717
|
+
|
|
718
|
+
IDEMPOTENT by identity: already holding this exact object → no-op (the wrapper
|
|
719
|
+
hands back the SAME parsed object every frame until a re-parse; without this
|
|
720
|
+
the render loop spins). RAW dict ops, so it doesn't re-fire _mark_changed."""
|
|
721
|
+
if value is self:
|
|
722
|
+
return
|
|
723
|
+
if self.value_key is not None:
|
|
724
|
+
if self.get(self.value_key) is value:
|
|
725
|
+
return
|
|
726
|
+
new = {self.value_key: value}
|
|
727
|
+
elif isinstance(value, dict):
|
|
728
|
+
if len(self) == len(value) and all(self.get(k) is v for k, v in value.items()):
|
|
729
|
+
return
|
|
730
|
+
new = value
|
|
731
|
+
else:
|
|
732
|
+
new = {"value": value}
|
|
733
|
+
dict.clear(self)
|
|
734
|
+
dict.update(self, new)
|
|
735
|
+
_reinstall_children(self, self)
|
|
736
|
+
# The held value just CHANGED - invalidate any external consumer subtrees so they
|
|
737
|
+
# re-run and pick it up (the background-parse-lands-into-a-cached-context-menu
|
|
738
|
+
# case).
|
|
739
|
+
self._notify_consumers(name="RenderHost value materialized")
|
|
740
|
+
request_render()
|
|
741
|
+
|
|
742
|
+
def _notify_consumers(self, name="RenderHost value changed"):
|
|
743
|
+
"""Invalidate external consumer subtrees (notify_on_change) — DEBOUNCED
|
|
744
|
+
against active typing. While the held value is being locally edited
|
|
745
|
+
(a keystroke stream — each edit stamps _local_edit_time), every finished
|
|
746
|
+
background reconvert would pulse here and invalidate EVERY consumer,
|
|
747
|
+
including a second editor window over the same file, per keystroke —
|
|
748
|
+
large-file re-renders that stall typing. Instead the notify is deferred
|
|
749
|
+
(trailing, re-armed per edit) and fires ONCE, a quiet interval after the
|
|
750
|
+
last local edit. Notifies with no recent local edit (external reload,
|
|
751
|
+
initial load) pass through immediately. The typing editor itself doesn't
|
|
752
|
+
need the pulse — its keystrokes render through the focused editor
|
|
753
|
+
directly."""
|
|
754
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
755
|
+
window = Toggles.HostLifecycle.consumer_notify_debounce_ms / 1000.0
|
|
756
|
+
remaining = self._local_edit_time + window - time.monotonic()
|
|
757
|
+
if remaining > 0:
|
|
758
|
+
self._pending_notify_name = name
|
|
759
|
+
self._arm_notify_timer(remaining)
|
|
760
|
+
return
|
|
761
|
+
self._notify_consumers_now(name)
|
|
762
|
+
|
|
763
|
+
def _arm_notify_timer(self, delay):
|
|
764
|
+
prev = self._notify_timer
|
|
765
|
+
if prev is not None:
|
|
766
|
+
prev.cancel()
|
|
767
|
+
t = threading.Timer(delay, self._flush_pending_notify)
|
|
768
|
+
t.daemon = True
|
|
769
|
+
self._notify_timer = t
|
|
770
|
+
t.start()
|
|
771
|
+
|
|
772
|
+
def _flush_pending_notify(self):
|
|
773
|
+
"""Timer body: fire the deferred consumer notify — unless another local
|
|
774
|
+
edit landed while waiting, in which case re-arm for the remainder (the
|
|
775
|
+
trailing-debounce re-arm for edits that produced no new pulse)."""
|
|
776
|
+
self._notify_timer = None
|
|
777
|
+
name = self._pending_notify_name
|
|
778
|
+
if name is None:
|
|
779
|
+
return
|
|
780
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
781
|
+
window = Toggles.HostLifecycle.consumer_notify_debounce_ms / 1000.0
|
|
782
|
+
remaining = self._local_edit_time + window - time.monotonic()
|
|
783
|
+
if remaining > 0:
|
|
784
|
+
self._arm_notify_timer(remaining)
|
|
785
|
+
return
|
|
786
|
+
self._pending_notify_name = None
|
|
787
|
+
self._notify_consumers_now(f"{name} (debounced)")
|
|
788
|
+
|
|
789
|
+
def _notify_consumers_now(self, name):
|
|
790
|
+
"""The actual invalidation fan-out: they read this host's value/error from
|
|
791
|
+
OUTSIDE its own draw loop, so nothing else re-runs them. Climb their
|
|
792
|
+
ancestors (invalidate_up) since the consumer is usually a nested cached
|
|
793
|
+
view that won't re-run unless its parents do."""
|
|
794
|
+
# Snapshot: this can run on a background worker (via _materialize) while
|
|
795
|
+
# the render thread rebuilds _consumers in notify_on_change / the sweep -
|
|
796
|
+
# a live dict would raise "changed size during iteration".
|
|
797
|
+
# max_depth: the consumer renders THIS host's data, so a new value must
|
|
798
|
+
# invalidate its whole cached subtree - the blit cache has no data
|
|
799
|
+
# key, children replay purely on tile dirtiness, and a depth-2 cascade
|
|
800
|
+
# left every deeper view compositing its old capture (the window
|
|
801
|
+
# re-drew, flickered, and still showed stale data until a hover
|
|
802
|
+
# invalidated the subtree for real). Depth 8 covers the deepest
|
|
803
|
+
# consumer trees; the cascade's clip gate already skips scrolled-out
|
|
804
|
+
# children (invalidate_scrolled_in catches those later), and this
|
|
805
|
+
# runs once per materialized value, not per frame.
|
|
806
|
+
for cds in list(self._consumers):
|
|
807
|
+
tid = getattr(cds, "_tile_id", None)
|
|
808
|
+
if tid is not None:
|
|
809
|
+
Melty.cache.invalidate_up(tid, force=True, max_depth=8,
|
|
810
|
+
note=Note(name=name,
|
|
811
|
+
tint=(0.4, 1.0, 0.6), draw_state=cds))
|
|
812
|
+
# notify("invalidate #8", tag="host", tint=(1, 0, 1))
|
|
813
|
+
|
|
814
|
+
if self._consumers:
|
|
815
|
+
request_render()
|
|
816
|
+
|
|
817
|
+
def _held(self):
|
|
818
|
+
"""The single held value (string / tree) the wrapper edits — None until first
|
|
819
|
+
materialized (so a not-yet-ready upstream proxy resolves to None). The whole
|
|
820
|
+
dict in mirror mode (value_key=None)."""
|
|
821
|
+
if self.value_key is not None:
|
|
822
|
+
return self.get(self.value_key)
|
|
823
|
+
return self
|
|
824
|
+
|
|
825
|
+
_outbound_value = _held
|
|
826
|
+
|
|
827
|
+
def _set_held(self, value):
|
|
828
|
+
"""Cross-proxy write: a downstream proxy pushes the wrapper's output (a new
|
|
829
|
+
source string) onto this upstream proxy → it surfaces to ITS wrapper and saves.
|
|
830
|
+
|
|
831
|
+
The LOCAL __setitem__ value is AUTHORITATIVE: a pending local edit (dirty — the
|
|
832
|
+
user just typed) is NOT overwritten, so a late/stale background result (a
|
|
833
|
+
debounced chain_out snapshotted frames ago) can't clobber what's being typed.
|
|
834
|
+
All O(1) — no content comparison."""
|
|
835
|
+
if self.value_key is None:
|
|
836
|
+
return
|
|
837
|
+
if self._external_change:
|
|
838
|
+
return # pending local edit wins over a write-back
|
|
839
|
+
if self.get(self.value_key) is value:
|
|
840
|
+
return # same object - no change
|
|
841
|
+
self[self.value_key] = value
|
|
842
|
+
|
|
843
|
+
# ── Lifecycle: draw the wrapper in this host's window (draw_main calls this) ─
|
|
844
|
+
def draw_needed(self):
|
|
845
|
+
"""Event-driven pump gate for draw_main's host loop: False when this
|
|
846
|
+
is a hidden cache host with nothing to do this frame, so keeping
|
|
847
|
+
dozens of code-cache pairs REGISTERED costs ~nothing (each polled
|
|
848
|
+
draw is ~0.1ms of pure wrapper/envelope overhead). All real work is
|
|
849
|
+
already invalidation-driven — the io bodies are use_cache=True, so on
|
|
850
|
+
a clean frame they blit-replay and do nothing — which makes "has
|
|
851
|
+
work" exactly:
|
|
852
|
+
- a flagged edit (_external_change / _pending_external),
|
|
853
|
+
- the upstream value changed identity (what draw() polls for),
|
|
854
|
+
- never drawn yet (initial load pending),
|
|
855
|
+
- the envelope/wrapper tile is invalidated (any ds.invalidate —
|
|
856
|
+
_mark_changed, run_in_background completions, consumer notifies),
|
|
857
|
+
- the slow staggered heartbeat (1-in-30): the safety net for
|
|
858
|
+
signals that don't touch tiles (debounce timers, missed edges) —
|
|
859
|
+
a missed wake costs ≤~250ms on pipelines already debounced by
|
|
860
|
+
hundreds of ms, and the save channel can never wedge.
|
|
861
|
+
Visible hosts (real windows / non-## names / non-evictable) always
|
|
862
|
+
draw — they are on screen every frame by definition."""
|
|
863
|
+
# A file-watch dispatch flagged the wrapper ds (external file change).
|
|
864
|
+
# The flag alone just bypasses the wrapper's OWN cache gate - but if
|
|
865
|
+
# the envelope above it is blit-cached, the replay never descends to
|
|
866
|
+
# the wrapper, the gate is never consulted, and the flag sits
|
|
867
|
+
# unconsumed forever (the io body never reloads). Invalidate this
|
|
868
|
+
# host's parent tile chain so the walk actually reaches the wrapper.
|
|
869
|
+
# Cheap and self-limiting: one host's tiles, and only until the body
|
|
870
|
+
# runs and clears the flag.
|
|
871
|
+
wds0 = self._wrapper_draw_state
|
|
872
|
+
if wds0 is not None and getattr(wds0, "_external_change", False):
|
|
873
|
+
if Melty.cache is not None and wds0._tile_id is not None:
|
|
874
|
+
Melty.cache.invalidate_up(wds0._tile_id, force=True, max_depth=4)
|
|
875
|
+
return True
|
|
876
|
+
# Every host is windowed (draw() draws each a stub envelope parked
|
|
877
|
+
# entirely OFF the screen - invisible chrome; the DATA windows a host
|
|
878
|
+
# opens are separate subwindows drawn from draw_main's registered loop), so
|
|
879
|
+
# `self.window` used to short-circuit this gate for ALL hosts and the
|
|
880
|
+
# event-driven pump below never ran: 17 hosts × ~0.2 ms of envelope +
|
|
881
|
+
# io-wrapper blit-replay on every frame the mouse wasn't held (Lukas
|
|
882
|
+
# 08-31: dropped the window term - flags, upstream identity, dirty
|
|
883
|
+
# tiles and the heartbeat cover every possible reason to draw).
|
|
884
|
+
if self.hidden:
|
|
885
|
+
return False
|
|
886
|
+
if self._external_change or self._pending_external:
|
|
887
|
+
return True
|
|
888
|
+
iv = self.input_value
|
|
889
|
+
if isinstance(iv, RenderHost):
|
|
890
|
+
iv = iv._held()
|
|
891
|
+
if iv is not self._last_resolved:
|
|
892
|
+
return True
|
|
893
|
+
env_ds = getattr(self, "_draw_state", None)
|
|
894
|
+
wds = self._wrapper_draw_state
|
|
895
|
+
if env_ds is None or wds is None:
|
|
896
|
+
return True
|
|
897
|
+
cache = Melty.cache
|
|
898
|
+
if cache is None or not cache.enabled:
|
|
899
|
+
# No blit cache (a meltygui app's surface, tests): no tiles, so
|
|
900
|
+
# nothing that can carry a dirty mark: a run_in_background
|
|
901
|
+
# completion (Melty.cache.invalidate on the io view's key) would
|
|
902
|
+
# only reach this host on the 1-in-30 heartbeat - which an
|
|
903
|
+
# edit-request app that idles between events never counts up to:
|
|
904
|
+
# the code editor's file host sat on its loading stand-in until
|
|
905
|
+
# a stray click (09-13). Poll instead: the io bodies are cheap
|
|
906
|
+
# (~0.1 ms of wrapper each) and writes only happen on request.
|
|
907
|
+
return True
|
|
908
|
+
if cache is not None:
|
|
909
|
+
for ds in (env_ds, wds):
|
|
910
|
+
tile = cache._tiles.get(getattr(ds, "_tile_id", None))
|
|
911
|
+
if tile is None:
|
|
912
|
+
# No tile: a zero-size host view (the voxel / line hosts'
|
|
913
|
+
# wrappers are 0 px wide - their output flows through
|
|
914
|
+
# the held value, not pixels) never gets one, and
|
|
915
|
+
# _is_dirty(None) read as "dirty" every frame. Only a
|
|
916
|
+
# NEVER-drawn view needs a first run.
|
|
917
|
+
if getattr(ds, "frame_count", 0) == 0:
|
|
918
|
+
return True
|
|
919
|
+
continue
|
|
920
|
+
if cache._is_dirty(tile):
|
|
921
|
+
return True
|
|
922
|
+
return (Melty.frame_count + (id(self) >> 4)) % 30 == 0
|
|
923
|
+
|
|
924
|
+
@staticmethod
|
|
925
|
+
def stub_width(stub_size):
|
|
926
|
+
"""The width the stub envelope actually renders at: the size draw()
|
|
927
|
+
asks for, floored by Mode.HOST_WINDOW's min_width (the wrapper clamps
|
|
928
|
+
`width` up to it). draw() parks the envelope this far off the display."""
|
|
929
|
+
from typing import Any
|
|
930
|
+
from meltygui.core.rendering.mode import Mode
|
|
931
|
+
floor = Mode.HOST_WINDOW.value[Any].kwargs.get("min_width", 0)
|
|
932
|
+
return max(stub_size, floor)
|
|
933
|
+
|
|
934
|
+
def draw(self, **extra):
|
|
935
|
+
"""Drive the wrapper for one frame. Resolve input_value (an upstream proxy →
|
|
936
|
+
its held value), run the wrapper with our private view_func via the window
|
|
937
|
+
envelope, and write the wrapper's output back upstream on edit."""
|
|
938
|
+
if self.hidden:
|
|
939
|
+
return None
|
|
940
|
+
from meltygui.core.rendering.mode import Mode
|
|
941
|
+
|
|
942
|
+
iv = self.input_value
|
|
943
|
+
if isinstance(iv, RenderHost):
|
|
944
|
+
iv = iv._held()
|
|
945
|
+
|
|
946
|
+
# Detect the input change HERE, not in render_host_view. draw() runs EVERY frame
|
|
947
|
+
# (draw_main is uncached), whereas render_host_view's window body is blit-cached
|
|
948
|
+
# and won't run on the frame an upstream proxy's value changed - so detecting it
|
|
949
|
+
# there drops the external_change event intermittently. On a change: flag it for
|
|
950
|
+
# the wrapper (so convert re-parses) AND invalidate the wrapper so its body
|
|
951
|
+
# actually re-runs to consume the flag. Identity compare - a changed source is a
|
|
952
|
+
# new string object; no content comparison needed.
|
|
953
|
+
|
|
954
|
+
draw=False
|
|
955
|
+
if iv is not self._last_resolved:
|
|
956
|
+
self._last_resolved = iv
|
|
957
|
+
self._pending_external = True
|
|
958
|
+
self._input_change_frame = Melty.frame_count # stamp: the input (source) changed here
|
|
959
|
+
# Invalidate BOTH the window envelope AND the wrapper's draw_state. The
|
|
960
|
+
# envelope so render_host_view re-runs and CALLS the wrapper; the wrapper
|
|
961
|
+
# so its blit-cached body actually re-executes (re-parses) - invalidating
|
|
962
|
+
# only the envelope re-calls a wrapper that just replays its cache, so the
|
|
963
|
+
# re-parse never happens and the change is lost (an intermittent bug).
|
|
964
|
+
for ds in (None, self._wrapper_draw_state):
|
|
965
|
+
if ds is not None:
|
|
966
|
+
note = Note(name="Renderhost _last_resolved", tint=(1, 0.0, 0.0), draw_state=ds)
|
|
967
|
+
# ds.invalidate(note=note)
|
|
968
|
+
# ds.invalidate_by_obj(obj=self, note=note)
|
|
969
|
+
notify("invalidate #9", tag="host", tint=(1, 0, 1))
|
|
970
|
+
draw=True
|
|
971
|
+
|
|
972
|
+
request_render()
|
|
973
|
+
|
|
974
|
+
# The envelope is chrome only (a host's DATA windows are separate
|
|
975
|
+
# @windows), so it is parked entirely OFF the display: its right edge
|
|
976
|
+
# sits `stub_gap` px left of x = 0, where nothing paints and nothing
|
|
977
|
+
# hit-tests. The mode's min_width / min_height floor the 40 px asked
|
|
978
|
+
# for (Mode outranks the caller, style guide rule 5), so the offset
|
|
979
|
+
# is taken off the floored width — it used to sit at x = -38 with the
|
|
980
|
+
# 50 px floor and showed a 12 px sliver down the left edge.
|
|
981
|
+
# [tint=(0.3, 0.0, 0.7)] stub_size = 40
|
|
982
|
+
# [tint=(0.3, 0.0, 0.7)] stub_gap = 10
|
|
983
|
+
# [tint=(0.3, 0.0, 0.7)] stub_top = 100
|
|
984
|
+
stub_size = 40
|
|
985
|
+
stub_gap = 10
|
|
986
|
+
stub_top = 100
|
|
987
|
+
win_kwargs = {"name": self.name}
|
|
988
|
+
if self.window:
|
|
989
|
+
win_kwargs.setdefault("mode", Mode.HOST_WINDOW)
|
|
990
|
+
win_kwargs["active_layer"] = 1
|
|
991
|
+
win_kwargs["unmanaged"] = True
|
|
992
|
+
win_kwargs["closed"] = False
|
|
993
|
+
win_kwargs['height'] = stub_size
|
|
994
|
+
win_kwargs['width'] = stub_size
|
|
995
|
+
win_kwargs["window_pos"] = (-(self.stub_width(stub_size) + stub_gap), stub_top)
|
|
996
|
+
|
|
997
|
+
win_kwargs.update(extra)
|
|
998
|
+
|
|
999
|
+
RenderHost._active.append(self)
|
|
1000
|
+
try:
|
|
1001
|
+
win_kwargs['tint'] = (0.3, 0, 0.7, 0.1)
|
|
1002
|
+
# win_kwargs['height'] = 40
|
|
1003
|
+
|
|
1004
|
+
result = render_host_view(iv, draw=draw, **win_kwargs)
|
|
1005
|
+
finally:
|
|
1006
|
+
RenderHost._active.pop()
|
|
1007
|
+
self._last_return = result if isinstance(result, tuple) else (False, result)
|
|
1008
|
+
|
|
1009
|
+
return self._last_return[1]
|
|
1010
|
+
|
|
1011
|
+
def __repr__(self):
|
|
1012
|
+
w = getattr(self.io_function, "__name__", self.io_function)
|
|
1013
|
+
flags = [f for f in ("hidden" if self.hidden else "", "dirty" if self._external_change else "",
|
|
1014
|
+
"" if self.standalone else "embedded") if f]
|
|
1015
|
+
tail = f" [{', '.join(flags)}]" if flags else ""
|
|
1016
|
+
return f"RenderHost({self.name!r} -> {w}{tail}, {dict.__repr__(self)})"
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
@render_func(use_cache=True, selectable=False, temp=True, view_func_selection=False)
|
|
1020
|
+
def render_host_view(input_value, external_change=False, draw=False, draw_state=None, name=None, **kwargs):
|
|
1021
|
+
"""Window envelope + wrapper driver for a RenderHost (draw_main → host.draw()).
|
|
1022
|
+
|
|
1023
|
+
A render_func so the host gets a Melty window (mode=Mode.WINDOW chrome here) and a
|
|
1024
|
+
persistent draw_state. It runs the host's wrapper with the host's private
|
|
1025
|
+
view_func, computes the wrapper's `external_change` (the resolved input changed
|
|
1026
|
+
since last frame — so convert's chain_in re-parses on new source; code_file_io
|
|
1027
|
+
self-manages and ignores it), and writes the wrapper's output back to the upstream
|
|
1028
|
+
proxy on edit.
|
|
1029
|
+
|
|
1030
|
+
Resolves its host by NAME, not the ambient `_active` stack: a Mode.WINDOW body
|
|
1031
|
+
renders DEFERRED (at frame end), after `draw()` has popped `_active`, so
|
|
1032
|
+
`RenderHost.current()` is None by then — the name (a passed kwarg) survives."""
|
|
1033
|
+
host = RenderHost.current()
|
|
1034
|
+
if host is None and name is not None:
|
|
1035
|
+
host = next((h for h in Melty.render_hosts.values() if h.name == name), None)
|
|
1036
|
+
if host is None:
|
|
1037
|
+
return False, input_value
|
|
1038
|
+
host._draw_state = draw_state
|
|
1039
|
+
|
|
1040
|
+
# data-bag (no wrapper): just render the dict itself.
|
|
1041
|
+
if host.io_function is None:
|
|
1042
|
+
from meltygui.view.collection_view import draw_collection
|
|
1043
|
+
return draw_collection(host, name=host.name)
|
|
1044
|
+
|
|
1045
|
+
# external_change for the wrapper: the framework's, OR the input-changed flag that
|
|
1046
|
+
# draw() set this frame (computed there because draw() runs every frame; consuming
|
|
1047
|
+
# it here, in the deferred/cached body, is reliable since draw() also runs us).
|
|
1048
|
+
ext = bool(external_change or host._pending_external)
|
|
1049
|
+
|
|
1050
|
+
# return_extras=True → the wrapper hands back its OWN draw_state as a 3rd value;
|
|
1051
|
+
# store it so a later value edit can invalidate it (see _mark_changed) and the
|
|
1052
|
+
# wrapper actually re-runs instead of replaying its blit cache. NOTE: reset
|
|
1053
|
+
# _pending_external AFTER the wrapper - _internal_view_func reads it (during this
|
|
1054
|
+
# call) to tell a genuine upstream change from the wrapper's own per-keystroke pulse.
|
|
1055
|
+
host.child_kwargs['temp'] = True
|
|
1056
|
+
notify(f"{host.io_function.__name__}", tag="host", tint=(1, 0.5, 0.5))
|
|
1057
|
+
host.child_kwargs["input_value"] = input_value
|
|
1058
|
+
result = host.io_function(draw=draw, view_func=host._internal_view_func,
|
|
1059
|
+
external_change=ext, return_extras=True, **host.child_kwargs)
|
|
1060
|
+
host._pending_external = False
|
|
1061
|
+
if isinstance(result, tuple) and len(result) == 3:
|
|
1062
|
+
edited, out, host._wrapper_draw_state = result
|
|
1063
|
+
else:
|
|
1064
|
+
edited, out = result
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
# Cross-proxy write-back: the wrapper's output (e.g. convert's new source string)
|
|
1068
|
+
# lands on the upstream proxy, which goes dirty and saves one frame later.
|
|
1069
|
+
if edited and isinstance(host.input_value, RenderHost):
|
|
1070
|
+
host.input_value._set_held(out)
|
|
1071
|
+
# A PROGRAMMATIC edit just serialized (chain_out from a param panel /
|
|
1072
|
+
# lens / live-view window - typing never takes this branch: keystrokes
|
|
1073
|
+
# enter the str host directly and don't chain_out). The code editor
|
|
1074
|
+
# over this file is also consumer of this dict host (notify_on_change,
|
|
1075
|
+
# draw_text_from_code_cache), but the regular consumer notify is
|
|
1076
|
+
# trailing-debounced ~2s against typing and re-armed by the
|
|
1077
|
+
# _mark_changed - under quick successive edits it starves and the
|
|
1078
|
+
# editor keeps showing the pre-edit cache. Notify NOW: one fan-out per
|
|
1079
|
+
# completed edit round, the editors re-run and read the fresh held
|
|
1080
|
+
# string.
|
|
1081
|
+
host._notify_consumers_now("programmatic edit write-back")
|
|
1082
|
+
|
|
1083
|
+
return edited, out
|