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,2636 @@
|
|
|
1
|
+
"""Orchestrator: record and replay user input (mouse, keys, scroll, text),
|
|
2
|
+
managed as a collection of named orchestrations (AppModel.orchestrations).
|
|
3
|
+
|
|
4
|
+
Recording taps the ONE funnel all real input flows through —
|
|
5
|
+
InputHandler.feed_down/up/move/change (via input_handler.set_input_tap) plus
|
|
6
|
+
the key/char callbacks in event_backends.py — so a take is the same stream
|
|
7
|
+
the app saw. Replay re-feeds that stream into the same handler
|
|
8
|
+
(Orchestrator.pump, driven from Melty right before process_frame) and stamps
|
|
9
|
+
a VIRTUAL cursor / buttons / modifiers over imgui's io
|
|
10
|
+
(Orchestrator.stamp_io, from SplitOverlayRenderer.process_inputs), so
|
|
11
|
+
nothing touches the real pointer — no Wayland warping, and real input is
|
|
12
|
+
muted at the funnel while a replay drives (Esc aborts).
|
|
13
|
+
|
|
14
|
+
Replay is not blind: while recording, every new GROUP on the undo stacks
|
|
15
|
+
(UndoManager edits + NavUndo window/location steps) is stamped as a CUE at
|
|
16
|
+
the current event index. Replay pauses at each cue until the live stack
|
|
17
|
+
shows a matching change; a missing cue first runs the cue target's
|
|
18
|
+
PRECONDITIONS (the same list the window shows beside the command — closed
|
|
19
|
+
/ collapsed / scrolled out / covered, satisfied by change_value's solver
|
|
20
|
+
at the recorded press point) and replays the gesture, for ANY cue with a
|
|
21
|
+
target regardless of kind or stack; a cue without a target gets the old
|
|
22
|
+
tile re-aim (edits) or none (effects); a second miss aborts with a notice
|
|
23
|
+
naming what was tried. With an orchestration's restore checkbox on, finishing a
|
|
24
|
+
replay walks both undo stacks back to where they stood at replay start —
|
|
25
|
+
the undo stack IS the restore mechanism.
|
|
26
|
+
|
|
27
|
+
The window is fast_dock-style: raw draw-list rows, manual hit-testing,
|
|
28
|
+
clicks resolved from left_mouse_down while hovered; orchestrator_sync()
|
|
29
|
+
(called once per frame from the always-rendering root) polls cue capture
|
|
30
|
+
and repaints the cached tile when engine state changes.
|
|
31
|
+
"""
|
|
32
|
+
import collections
|
|
33
|
+
import time
|
|
34
|
+
import types
|
|
35
|
+
|
|
36
|
+
import meltygui.core.windowing.window_api as glfw
|
|
37
|
+
import meltygui_imgui as imgui
|
|
38
|
+
from meltygui.hdr_color import pack_color
|
|
39
|
+
|
|
40
|
+
from meltygui.core.melty import Melty
|
|
41
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
42
|
+
from meltygui.core.windowing.glfw_utils import request_render
|
|
43
|
+
from meltygui.core.input.input_handler import set_input_tap
|
|
44
|
+
from meltygui.core.diagnostics.notifications import notify
|
|
45
|
+
from meltygui.core.cache.tile_cache import add_shadow
|
|
46
|
+
from meltygui.core.core_render import render_func
|
|
47
|
+
from meltygui.core.conversion.dict_conversion import DictConversion
|
|
48
|
+
from meltygui.state.core_undo import UndoManager
|
|
49
|
+
from meltygui.state.core_undo import NavUndo
|
|
50
|
+
from meltygui.state.core_undo import WindowChange
|
|
51
|
+
from meltygui.state.core_undo import WindowMoveChange
|
|
52
|
+
from meltygui.core.rendering.window_decoration import window
|
|
53
|
+
|
|
54
|
+
# input_id -> imgui io.mouse_down index (the buttons stamp_io overrides).
|
|
55
|
+
_IMGUI_BUTTON = {"left_mouse": 0, "right_mouse": 1, "middle_mouse": 2}
|
|
56
|
+
|
|
57
|
+
# Feed ids / key codes of the stop hotkey (Ctrl+Shift+O), trimmed off a
|
|
58
|
+
# take's tail when the hotkey stops the recording.
|
|
59
|
+
_HOTKEY_KEYS = (glfw.KEY_O, glfw.KEY_LEFT_CONTROL, glfw.KEY_RIGHT_CONTROL,
|
|
60
|
+
glfw.KEY_LEFT_SHIFT, glfw.KEY_RIGHT_SHIFT)
|
|
61
|
+
_HOTKEY_FEED_IDS = {f"key_{key}" for key in _HOTKEY_KEYS}
|
|
62
|
+
|
|
63
|
+
# Remap value meaning "no override": open the target's gates, then play the
|
|
64
|
+
# recorded gesture verbatim.
|
|
65
|
+
_KEEP = object()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _stacks():
|
|
69
|
+
"""The undo timelines cues are cut from and restore unwinds — name ->
|
|
70
|
+
UndoStack. Restore runs in this dict's ORDER: edits first (their undo
|
|
71
|
+
requests need the views still open), navigation after. (The effects
|
|
72
|
+
LEDGER below is the third cue source — observable but not undoable, so
|
|
73
|
+
it is deliberately not in this dict: restore never unwinds it.)"""
|
|
74
|
+
return {"edits": UndoManager.stack, "navigation": NavUndo.stack}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EffectLedger:
|
|
78
|
+
"""Observable-but-not-undoable effects — the third cue source beside the
|
|
79
|
+
two undo stacks. Framework points publish one line through
|
|
80
|
+
Melty.effect_hook when something REAL happened that no stack records:
|
|
81
|
+
an actual window raise (apply_move_to_front — what makes a fast-dock
|
|
82
|
+
row click verifiable with zero dock structure: the click is identified
|
|
83
|
+
by its effect, "Voxels came to front", not by which pixel was hit), a
|
|
84
|
+
fired flat_button (headers.flat_button — view_id + rect, so button cues
|
|
85
|
+
carry the same press-fraction geometry as leaf-editor cues).
|
|
86
|
+
|
|
87
|
+
`seq` plays the role group_id plays for the stacks: recording sweeps
|
|
88
|
+
entries past its mark into cues (stack="effects"), replay verification
|
|
89
|
+
scans entries past the replay mark. Bounded ring; class attrs so
|
|
90
|
+
hotswap keeps live state."""
|
|
91
|
+
|
|
92
|
+
entries = collections.deque(maxlen=256)
|
|
93
|
+
next_seq = 0
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def note(cls, kind, name, ds=None, rect=None):
|
|
97
|
+
cls.next_seq += 1
|
|
98
|
+
cls.entries.append(types.SimpleNamespace(
|
|
99
|
+
seq=cls.next_seq, kind=str(kind), name=str(name),
|
|
100
|
+
draw_state=ds, rect=rect, frame=Melty.frame_count))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _wrap_text(text, width):
|
|
104
|
+
"""Greedy word wrap for the draw list (imgui measures, we break): words
|
|
105
|
+
longer than the width break mid-word so nothing overflows."""
|
|
106
|
+
lines = []
|
|
107
|
+
for paragraph in str(text).split("\n"):
|
|
108
|
+
line = ""
|
|
109
|
+
for word in paragraph.split(" "):
|
|
110
|
+
candidate = word if not line else f"{line} {word}"
|
|
111
|
+
if imgui.calc_text_size(candidate)[0] <= width or not line:
|
|
112
|
+
line = candidate
|
|
113
|
+
else:
|
|
114
|
+
lines.append(line)
|
|
115
|
+
line = word
|
|
116
|
+
while imgui.calc_text_size(line)[0] > width and len(line) > 1:
|
|
117
|
+
cut = len(line)
|
|
118
|
+
while cut > 1 and imgui.calc_text_size(line[:cut])[0] > width:
|
|
119
|
+
cut -= 1
|
|
120
|
+
lines.append(line[:cut])
|
|
121
|
+
line = line[cut:]
|
|
122
|
+
lines.append(line)
|
|
123
|
+
return lines or [""]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _short_repr(value):
|
|
127
|
+
try:
|
|
128
|
+
return repr(value)[:120]
|
|
129
|
+
except Exception:
|
|
130
|
+
return "?"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Legacy cue tuples (recorded before cues became dicts) read from this
|
|
134
|
+
# position map; every cue field access goes through cue_get so old takes
|
|
135
|
+
# keep replaying. New cues are dicts (see make_cue) - serializable, and
|
|
136
|
+
# extensible without another positional migration.
|
|
137
|
+
_CUE_TUPLE_FIELDS = {"at": 0, "stack": 1, "kind": 2, "name": 3,
|
|
138
|
+
"new_repr": 4, "tile": 5, "anchor": 6}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cue_get(cue, field, default=None):
|
|
142
|
+
if isinstance(cue, dict):
|
|
143
|
+
return cue.get(field, default)
|
|
144
|
+
index = _CUE_TUPLE_FIELDS.get(field)
|
|
145
|
+
if index is not None and len(cue) > index:
|
|
146
|
+
return cue[index]
|
|
147
|
+
return default
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _primitive_or_repr(value):
|
|
151
|
+
"""Cue values: numeric/str/bool primitives kept RAW (the servo computes
|
|
152
|
+
gain from them; text verification compares exactly), everything else a
|
|
153
|
+
repr string."""
|
|
154
|
+
if isinstance(value, (int, float, bool, str)) or value is None:
|
|
155
|
+
return value
|
|
156
|
+
return _short_repr(value)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _last_press_abs(take):
|
|
160
|
+
"""The take's most recent mouse press in ABSOLUTE coordinates —
|
|
161
|
+
whether it is still absolute or already claimed by an earlier cue
|
|
162
|
+
(window-relative, trailing cue index): un-anchor through that cue's
|
|
163
|
+
recorded origin. None when the take holds no press."""
|
|
164
|
+
for event in reversed(take.events):
|
|
165
|
+
if event[1] != "down":
|
|
166
|
+
continue
|
|
167
|
+
x, y = event[3], event[4]
|
|
168
|
+
if len(event) > 5:
|
|
169
|
+
cue_index = event[5]
|
|
170
|
+
anchor = (cue_get(take.cues[cue_index], "anchor")
|
|
171
|
+
if 0 <= cue_index < len(take.cues) else None)
|
|
172
|
+
if anchor is None:
|
|
173
|
+
return None
|
|
174
|
+
x, y = x + anchor[2], y + anchor[3]
|
|
175
|
+
return (x, y)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _press_abs_at(take, index):
|
|
180
|
+
"""Absolute (x, y) of the press event at `index` (un-anchored through
|
|
181
|
+
the cue it belongs to when relativized), or None."""
|
|
182
|
+
event = take.events[index]
|
|
183
|
+
if event[1] != "down":
|
|
184
|
+
return None
|
|
185
|
+
x, y = event[3], event[4]
|
|
186
|
+
if len(event) > 5:
|
|
187
|
+
cue_index = event[5]
|
|
188
|
+
anchor = (cue_get(take.cues[cue_index], "anchor")
|
|
189
|
+
if 0 <= cue_index < len(take.cues) else None)
|
|
190
|
+
if anchor is None:
|
|
191
|
+
return None
|
|
192
|
+
x, y = x + anchor[2], y + anchor[3]
|
|
193
|
+
return (x, y)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _group_press(take, since, rect):
|
|
197
|
+
"""(index, (x, y)) of the press that starts an edit cue's gesture: the
|
|
198
|
+
FIRST mouse press at/after `since` (the previous edit cue's claim) that
|
|
199
|
+
lands inside the target's `rect` (left, top, width, height). A colour
|
|
200
|
+
edit is chip click → popover drag with the cue cut mid-drag: the chip
|
|
201
|
+
click is the press on the target, the popover drag is outside it; a
|
|
202
|
+
text edit is focus click → keystrokes; a drag is its own press. A
|
|
203
|
+
stray click elsewhere (raising the window first) is outside the rect
|
|
204
|
+
and skipped. Falls back to the LAST press when none is inside."""
|
|
205
|
+
left, top, width, height = rect
|
|
206
|
+
last = None
|
|
207
|
+
for index in range(len(take.events)):
|
|
208
|
+
point = _press_abs_at(take, index) if take.events[index][1] == "down" \
|
|
209
|
+
and take.events[index][2] in _IMGUI_BUTTON else None
|
|
210
|
+
if point is None:
|
|
211
|
+
continue
|
|
212
|
+
last = (index, point)
|
|
213
|
+
if index < since:
|
|
214
|
+
continue
|
|
215
|
+
if width > 0 and height > 0 and left <= point[0] <= left + width \
|
|
216
|
+
and top <= point[1] <= top + height:
|
|
217
|
+
return index, point
|
|
218
|
+
return last if last is not None else (None, None)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def make_cue(change, stack_name, event_index, take, press_window=None, since=0):
|
|
222
|
+
"""The enriched cue: effect signature (kind/name/old/new/direction),
|
|
223
|
+
addressing (chain — maximal capture, minimal_path trims late; tile),
|
|
224
|
+
geometry (anchor window + the target leaf's rect and the press point as
|
|
225
|
+
a FRACTION of it — what re-targets a take onto a sibling field), and the
|
|
226
|
+
applicability signature (editor + value_type — what licenses reusing
|
|
227
|
+
this take on a different field of the same widget kind)."""
|
|
228
|
+
from meltygui.core.automation.selector_core import name_chain
|
|
229
|
+
ds = getattr(change, "draw_state", None)
|
|
230
|
+
anchored = _cue_anchor_window(change, press_window=press_window)
|
|
231
|
+
anchor = _cue_anchor(change, press_window=press_window)
|
|
232
|
+
leaf_rect = None
|
|
233
|
+
press_frac = None
|
|
234
|
+
press_index = None
|
|
235
|
+
if ds is not None:
|
|
236
|
+
left = getattr(ds, "abs_left", None)
|
|
237
|
+
top = getattr(ds, "abs_top", None)
|
|
238
|
+
width = getattr(ds, "width", 0) or 0
|
|
239
|
+
height = getattr(ds, "height", 0) or 0
|
|
240
|
+
if anchored is not None and anchored[0] is ds:
|
|
241
|
+
# the target IS the anchor window: its rect is read where the
|
|
242
|
+
# window sat when the press landed (the anchor origin), not
|
|
243
|
+
# where the gesture left it - a colour cue's press is on the
|
|
244
|
+
# window, and measured against the anchor rect it read as the
|
|
245
|
+
# bottom edge / a corner (clamped fraction, 09-01)
|
|
246
|
+
left, top = anchored[1], anchored[2]
|
|
247
|
+
if left is not None and top is not None and anchor is not None:
|
|
248
|
+
leaf_rect = (float(left - anchor[2]), float(top - anchor[3]),
|
|
249
|
+
float(width), float(height))
|
|
250
|
+
# the press that STARTED this cue's gesture and the fraction
|
|
251
|
+
# (group_press); a keyboard-only edit has no press and the
|
|
252
|
+
# executors default to the leaf's center
|
|
253
|
+
press_index, press = (_group_press(take, since, (left or 0, top or 0, width, height))
|
|
254
|
+
if left is not None else (None, None))
|
|
255
|
+
if press is not None and left is not None and width > 0 and height > 0:
|
|
256
|
+
press_frac = (max(0.0, min(1.0, (press[0] - left) / width)),
|
|
257
|
+
max(0.0, min(1.0, (press[1] - top) / height)))
|
|
258
|
+
view_func = getattr(ds, "_view_func", None) if ds is not None else None
|
|
259
|
+
return {
|
|
260
|
+
"at": event_index,
|
|
261
|
+
"stack": stack_name,
|
|
262
|
+
"kind": type(change).__name__,
|
|
263
|
+
"name": str(change.display_name),
|
|
264
|
+
"chain": list(name_chain(ds)) if ds is not None else [],
|
|
265
|
+
"old": _primitive_or_repr(change.old),
|
|
266
|
+
"new": _primitive_or_repr(change.new),
|
|
267
|
+
"new_repr": _short_repr(change.new),
|
|
268
|
+
"direction": getattr(change, "direction", None),
|
|
269
|
+
"editor": getattr(view_func, "__name__", None),
|
|
270
|
+
"value_type": type(change.new).__name__,
|
|
271
|
+
"anchor": anchor,
|
|
272
|
+
"leaf_rect": leaf_rect,
|
|
273
|
+
"press_frac": press_frac,
|
|
274
|
+
"press_index": press_index, # the press that starts the gesture (replay retargets from it)
|
|
275
|
+
"tile": repr(getattr(ds, "_tile_id", None))[:200],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _front_window_name():
|
|
280
|
+
"""Display name of the frontmost OPEN registered window (registration
|
|
281
|
+
order is z-order, front = last). The state side of raise verification."""
|
|
282
|
+
windows = getattr(Melty, "registered_windows", None) or {}
|
|
283
|
+
for managed in reversed(list(windows.values())):
|
|
284
|
+
ds = getattr(managed, "draw_state", None)
|
|
285
|
+
if ds is not None and not getattr(ds, "closed", False):
|
|
286
|
+
return str(getattr(managed, "name", "")).split("##")[0]
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _contains(ds, x, y):
|
|
291
|
+
left = getattr(ds, "abs_left", None)
|
|
292
|
+
top = getattr(ds, "abs_top", None)
|
|
293
|
+
if left is None or top is None:
|
|
294
|
+
return False
|
|
295
|
+
return (left <= x <= left + (getattr(ds, "width", 0) or 0)
|
|
296
|
+
and top <= y <= top + (getattr(ds, "height", 0) or 0))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _on_header_band(ds, x, y):
|
|
300
|
+
"""(x, y) inside ds's header strip (a window's title row)."""
|
|
301
|
+
header = getattr(ds, "header_height", 0) or 0
|
|
302
|
+
top = getattr(ds, "abs_top", None)
|
|
303
|
+
return (header > 0 and top is not None and _contains(ds, x, y)
|
|
304
|
+
and y < top + header)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _body_press_raise(window, press, home=None):
|
|
308
|
+
"""True when the press raised `window` as a side effect of hitting its
|
|
309
|
+
CONTENT: the press's home (`home`, the top-level view under it AT
|
|
310
|
+
PRESS TIME — a summoned window covers the point afterwards, so cue-time
|
|
311
|
+
geometry can't tell) is the window itself or a view nested in it, and
|
|
312
|
+
the point sits on no header band (the window's own, or a nested
|
|
313
|
+
window's — every hit-boxed view is checked). A press on a header, or
|
|
314
|
+
whose home is elsewhere (dock row, another window's control), is an
|
|
315
|
+
explicit raise. No home known → the window's geometry decides; no
|
|
316
|
+
geometry → explicit."""
|
|
317
|
+
if press is None or window is None:
|
|
318
|
+
return False
|
|
319
|
+
x, y = press
|
|
320
|
+
if home is not None:
|
|
321
|
+
node, steps = home, 0
|
|
322
|
+
while node is not None and node is not window and steps < 64:
|
|
323
|
+
parent = getattr(node, "parent_window", None)
|
|
324
|
+
if parent is node:
|
|
325
|
+
break
|
|
326
|
+
node, steps = parent, steps + 1
|
|
327
|
+
if node is not window:
|
|
328
|
+
return False # press-time home elsewhere
|
|
329
|
+
elif not _contains(window, x, y):
|
|
330
|
+
return False
|
|
331
|
+
if _on_header_band(window, x, y):
|
|
332
|
+
return False
|
|
333
|
+
for ds in (getattr(Melty, "_bvh_id_to_ds", None) or {}).values():
|
|
334
|
+
if ds is not None and _on_header_band(ds, x, y):
|
|
335
|
+
return False
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _window_under(x, y):
|
|
340
|
+
"""The click's HOME root: the topmost hit-boxed view under (x, y) — the
|
|
341
|
+
BVH holds EVERY live interactive view, which matters because the Fast
|
|
342
|
+
Dock is NOT a registered window (it draws straight from the root loop),
|
|
343
|
+
so a registered-windows-only scan missed it and dock clicks got no
|
|
344
|
+
anchor (absolute replay coordinates — the original-spot bug) — walked up
|
|
345
|
+
its parent_window chain to the top-level view whose position the take
|
|
346
|
+
should be relative to. Registered windows (reversed = front first) are
|
|
347
|
+
the fallback when the BVH is empty (headless)."""
|
|
348
|
+
best = None
|
|
349
|
+
best_z = None
|
|
350
|
+
for ds in (getattr(Melty, "_bvh_id_to_ds", None) or {}).values():
|
|
351
|
+
if ds is None or not _contains(ds, x, y):
|
|
352
|
+
continue
|
|
353
|
+
z = getattr(ds, "z_pos", None) or getattr(ds, "abs_layer", 0) or 0
|
|
354
|
+
if best_z is None or z >= best_z:
|
|
355
|
+
best, best_z = ds, z
|
|
356
|
+
if best is not None:
|
|
357
|
+
node, steps = best, 0
|
|
358
|
+
while steps < 64:
|
|
359
|
+
parent = getattr(node, "parent_window", None)
|
|
360
|
+
if parent is None or parent is node:
|
|
361
|
+
break
|
|
362
|
+
node, steps = parent, steps + 1
|
|
363
|
+
return node
|
|
364
|
+
windows = getattr(Melty, "registered_windows", None) or {}
|
|
365
|
+
for managed in reversed(list(windows.values())):
|
|
366
|
+
ds = getattr(managed, "draw_state", None)
|
|
367
|
+
if ds is not None and not getattr(ds, "closed", False) and _contains(ds, x, y):
|
|
368
|
+
return ds
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def make_effect_cue(entry, take, press_window=None):
|
|
373
|
+
"""Cue from an effect-ledger entry — same dict shape as make_cue so the
|
|
374
|
+
claiming / relativization / verification machinery serves it unchanged.
|
|
375
|
+
Anchor = the window under the recorded press AT PRESS TIME
|
|
376
|
+
(`press_window`, stashed by the tap when the down was recorded): by
|
|
377
|
+
cue-cut time the effect has already happened — the window the dock
|
|
378
|
+
summoned sits raised and topmost OVER the dock row, so a
|
|
379
|
+
resolve-at-cue-time anchored to the summoned window, not the dock.
|
|
380
|
+
leaf_rect / press_frac from the published control rect (flat_button)."""
|
|
381
|
+
from meltygui.core.automation.selector_core import name_chain
|
|
382
|
+
press = _last_press_abs(take)
|
|
383
|
+
window_ds = press_window if press_window is not None else (
|
|
384
|
+
_window_under(press[0], press[1]) if press is not None else None)
|
|
385
|
+
if press is None:
|
|
386
|
+
window_ds = None # a stash pairs with ITS effect only
|
|
387
|
+
if window_ds is None and entry.rect is None and entry.draw_state is not None:
|
|
388
|
+
window_ds = getattr(entry.draw_state, "parent_window", None)
|
|
389
|
+
anchor = None
|
|
390
|
+
if window_ds is not None:
|
|
391
|
+
left = getattr(window_ds, "abs_left", None)
|
|
392
|
+
top = getattr(window_ds, "abs_top", None)
|
|
393
|
+
if left is not None and top is not None:
|
|
394
|
+
anchor = (repr(getattr(window_ds, "_tile_id", None))[:200],
|
|
395
|
+
str(getattr(window_ds, "name", "?")).split("##")[0],
|
|
396
|
+
float(left), float(top))
|
|
397
|
+
leaf_rect = None
|
|
398
|
+
press_frac = None
|
|
399
|
+
press_offset = None
|
|
400
|
+
ds_for_offset = entry.draw_state
|
|
401
|
+
if press is not None and ds_for_offset is not None:
|
|
402
|
+
ds_left = getattr(ds_for_offset, "abs_left", None)
|
|
403
|
+
ds_top = getattr(ds_for_offset, "abs_top", None)
|
|
404
|
+
if ds_left is not None and ds_top is not None:
|
|
405
|
+
# where the press sat relative to the affected view's top-left:
|
|
406
|
+
# an expand arrow is a constant offset from its collection's
|
|
407
|
+
# corner whatever the collection, so this generalizes an
|
|
408
|
+
# expand demonstration to any collection gate
|
|
409
|
+
press_offset = (float(press[0] - ds_left), float(press[1] - ds_top))
|
|
410
|
+
if entry.rect is not None:
|
|
411
|
+
rect_x, rect_y, rect_w, rect_h = entry.rect
|
|
412
|
+
if anchor is not None:
|
|
413
|
+
leaf_rect = (float(rect_x - anchor[2]), float(rect_y - anchor[3]),
|
|
414
|
+
float(rect_w), float(rect_h))
|
|
415
|
+
if press is not None and rect_w > 0 and rect_h > 0:
|
|
416
|
+
press_frac = (max(0.0, min(1.0, (press[0] - rect_x) / rect_w)),
|
|
417
|
+
max(0.0, min(1.0, (press[1] - rect_y) / rect_h)))
|
|
418
|
+
return {
|
|
419
|
+
"at": len(take.events),
|
|
420
|
+
"stack": "effects",
|
|
421
|
+
"kind": entry.kind,
|
|
422
|
+
"name": entry.name,
|
|
423
|
+
"chain": list(name_chain(entry.draw_state)) if entry.draw_state is not None else [],
|
|
424
|
+
"old": None, "new": None, "new_repr": "", "direction": None,
|
|
425
|
+
# the view the effect happened on: what makes a demonstration
|
|
426
|
+
# transferable to subjects of the SAME kind (flat_value.effectable)
|
|
427
|
+
"editor": getattr(getattr(entry.draw_state, "_view_func", None), "__name__", None),
|
|
428
|
+
"value_type": None,
|
|
429
|
+
"anchor": anchor,
|
|
430
|
+
"leaf_rect": leaf_rect,
|
|
431
|
+
"press_frac": press_frac,
|
|
432
|
+
"press_offset": press_offset,
|
|
433
|
+
"tile": repr(getattr(entry.draw_state, "_tile_id", None))[:200],
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _cue_anchor(change, press_window=None):
|
|
438
|
+
"""The window a cue's events reference: (window_tile_repr, window_name,
|
|
439
|
+
abs_left, abs_top), or None (those events stay absolute).
|
|
440
|
+
|
|
441
|
+
- value Change: the field's PARENT WINDOW (the press was inside it).
|
|
442
|
+
- WindowChange: the window under the press AT PRESS TIME when known
|
|
443
|
+
(`press_window`, the tap's stash) — a dock row click toggles ANOTHER
|
|
444
|
+
window, and anchoring on the toggled window re-referenced the dock
|
|
445
|
+
click to wherever that window sits; the toggled window itself is only
|
|
446
|
+
the fallback (the titlebar-✕ case, where they coincide).
|
|
447
|
+
- WindowMoveChange: the moved window itself, at its PRE-drag position —
|
|
448
|
+
the drag really is on that window, and its events happened before the
|
|
449
|
+
move landed."""
|
|
450
|
+
resolved = _cue_anchor_window(change, press_window=press_window)
|
|
451
|
+
if resolved is None:
|
|
452
|
+
return None
|
|
453
|
+
window_ds, left, top = resolved
|
|
454
|
+
return (repr(getattr(window_ds, "_tile_id", None))[:200],
|
|
455
|
+
str(getattr(window_ds, "name", "?")), float(left), float(top))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _cue_anchor_window(change, press_window=None):
|
|
459
|
+
"""(window_ds, left, top) behind _cue_anchor: the anchor window and its
|
|
460
|
+
origin AT PRESS TIME — a moved window's is rewound by the change's
|
|
461
|
+
delta, since the cue is cut when the drag has landed but its events
|
|
462
|
+
(and the press's geometry) happened before. None when there is no
|
|
463
|
+
window to anchor on."""
|
|
464
|
+
ds = getattr(change, "draw_state", None)
|
|
465
|
+
if ds is None:
|
|
466
|
+
return None
|
|
467
|
+
if isinstance(change, WindowMoveChange):
|
|
468
|
+
window_ds = ds
|
|
469
|
+
elif isinstance(change, WindowChange):
|
|
470
|
+
window_ds = press_window if press_window is not None else ds
|
|
471
|
+
else:
|
|
472
|
+
window_ds = getattr(ds, "parent_window", None)
|
|
473
|
+
if window_ds is None:
|
|
474
|
+
return None
|
|
475
|
+
left = getattr(window_ds, "abs_left", None)
|
|
476
|
+
top = getattr(window_ds, "abs_top", None)
|
|
477
|
+
if left is None or top is None:
|
|
478
|
+
return None
|
|
479
|
+
if isinstance(change, WindowMoveChange):
|
|
480
|
+
left -= change.new[0] - change.old[0]
|
|
481
|
+
top -= change.new[1] - change.old[1]
|
|
482
|
+
return window_ds, float(left), float(top)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _key_label(key, mods=0):
|
|
486
|
+
"""Human name for a glfw key (+held modifiers): printable GLFW codes ARE
|
|
487
|
+
ASCII, the rest come from the backend's name table."""
|
|
488
|
+
from meltygui.core.input.pynput_backend import ImGuiBackend
|
|
489
|
+
if 32 <= key < 127:
|
|
490
|
+
name = chr(key)
|
|
491
|
+
else:
|
|
492
|
+
name = ImGuiBackend.KEY_NAMES.get(key, f"key_{key}")
|
|
493
|
+
prefix = "".join(part for bit, part in ((glfw.MOD_CONTROL, "ctrl+"),
|
|
494
|
+
(glfw.MOD_SHIFT, "shift+"),
|
|
495
|
+
(glfw.MOD_ALT, "alt+"),
|
|
496
|
+
(glfw.MOD_SUPER, "super+"))
|
|
497
|
+
if mods & bit)
|
|
498
|
+
return prefix + name
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def group_events(events, cues=()):
|
|
502
|
+
"""Collapse a take's raw event stream into readable rows for the window's
|
|
503
|
+
event list. Returns [(start_index, end_index, kind, label, tag)] where
|
|
504
|
+
[start_index, end_index) is the run of raw events the row covers (the
|
|
505
|
+
replay progress highlight reads it) and kind is one of "move", "click",
|
|
506
|
+
"drag", "down", "up", "type", "key", "scroll", "cue".
|
|
507
|
+
|
|
508
|
+
Grouping: a run of moves is one row; a down whose matching up follows
|
|
509
|
+
with only moves between is a CLICK (no moves) or a DRAG; consecutive
|
|
510
|
+
chars are one typed string; consecutive same-key presses fold with a
|
|
511
|
+
×count; scroll deltas on one axis sum. Runs SPLIT at any cue's
|
|
512
|
+
event_index so cue rows land exactly between the events they gate."""
|
|
513
|
+
boundaries = {cue_get(cue, "at", 0) for cue in cues}
|
|
514
|
+
cues_at = {}
|
|
515
|
+
for cue in cues:
|
|
516
|
+
cues_at.setdefault(cue_get(cue, "at", 0), []).append(cue)
|
|
517
|
+
|
|
518
|
+
def _emit_cues(rows, index):
|
|
519
|
+
for cue in cues_at.get(index, ()):
|
|
520
|
+
anchor = cue_get(cue, "anchor")
|
|
521
|
+
rows.append((index, index, "cue",
|
|
522
|
+
f"cue: {cue_get(cue, 'kind')} '{cue_get(cue, 'name')}'"
|
|
523
|
+
+ (f" @ {anchor[1]}" if anchor else ""),
|
|
524
|
+
cue_get(cue, "stack", "")))
|
|
525
|
+
|
|
526
|
+
def _run_end(start, predicate):
|
|
527
|
+
"""End of the run at `start` (exclusive), stopping at cue boundaries."""
|
|
528
|
+
end = start + 1
|
|
529
|
+
while end < len(events) and end not in boundaries and predicate(events[end]):
|
|
530
|
+
end += 1
|
|
531
|
+
return end
|
|
532
|
+
|
|
533
|
+
rows = []
|
|
534
|
+
i = 0
|
|
535
|
+
while i < len(events):
|
|
536
|
+
_emit_cues(rows, i)
|
|
537
|
+
event = events[i]
|
|
538
|
+
dt, kind = event[0], event[1]
|
|
539
|
+
if kind == "move":
|
|
540
|
+
end = _run_end(i, lambda ev: ev[1] == "move")
|
|
541
|
+
first, last = events[i], events[end - 1]
|
|
542
|
+
rows.append((i, end, "move",
|
|
543
|
+
f"move ({first[2]:.0f}, {first[3]:.0f}) → ({last[2]:.0f}, {last[3]:.0f})",
|
|
544
|
+
f"{end - i}× · {last[0] - first[0]:.1f}s"))
|
|
545
|
+
i = end
|
|
546
|
+
elif kind == "down":
|
|
547
|
+
input_id = event[2]
|
|
548
|
+
# matching up with only MOVES between (and no cue splitting it)
|
|
549
|
+
end = _run_end(i, lambda ev: ev[1] == "move")
|
|
550
|
+
if end < len(events) and end not in boundaries \
|
|
551
|
+
and events[end][1] == "up" and events[end][2] == input_id:
|
|
552
|
+
up = events[end]
|
|
553
|
+
move_count = end - i - 1
|
|
554
|
+
if move_count == 0:
|
|
555
|
+
rows.append((i, end + 1, "click",
|
|
556
|
+
f"click {input_id} @ ({event[3]:.0f}, {event[4]:.0f})",
|
|
557
|
+
f"{dt:.1f}s"))
|
|
558
|
+
else:
|
|
559
|
+
rows.append((i, end + 1, "drag",
|
|
560
|
+
f"drag {input_id} ({event[3]:.0f}, {event[4]:.0f})"
|
|
561
|
+
f" → ({up[3]:.0f}, {up[4]:.0f})",
|
|
562
|
+
f"{move_count} moves · {up[0] - dt:.1f}s"))
|
|
563
|
+
i = end + 1
|
|
564
|
+
else:
|
|
565
|
+
rows.append((i, i + 1, "down",
|
|
566
|
+
f"down {input_id} @ ({event[3]:.0f}, {event[4]:.0f})",
|
|
567
|
+
f"{dt:.1f}s"))
|
|
568
|
+
i += 1
|
|
569
|
+
elif kind == "up":
|
|
570
|
+
rows.append((i, i + 1, "up",
|
|
571
|
+
f"up {event[2]} @ ({event[3]:.0f}, {event[4]:.0f})",
|
|
572
|
+
f"{dt:.1f}s"))
|
|
573
|
+
i += 1
|
|
574
|
+
elif kind == "char":
|
|
575
|
+
end = _run_end(i, lambda ev: ev[1] == "char")
|
|
576
|
+
text = "".join(chr(ev[2]) for ev in events[i:end])
|
|
577
|
+
rows.append((i, end, "type", f'type "{text}"', f"{dt:.1f}s"))
|
|
578
|
+
i = end
|
|
579
|
+
elif kind == "key":
|
|
580
|
+
key, mods = event[2], event[3]
|
|
581
|
+
end = _run_end(i, lambda ev: ev[1] == "key"
|
|
582
|
+
and ev[2] == key and ev[3] == mods)
|
|
583
|
+
count = end - i
|
|
584
|
+
label = f"key {_key_label(key, mods)}"
|
|
585
|
+
rows.append((i, end, "key",
|
|
586
|
+
label + (f" ×{count}" if count > 1 else ""), f"{dt:.1f}s"))
|
|
587
|
+
i = end
|
|
588
|
+
elif kind == "change":
|
|
589
|
+
input_id = event[2]
|
|
590
|
+
end = _run_end(i, lambda ev: ev[1] == "change" and ev[2] == input_id)
|
|
591
|
+
total = sum(ev[3] for ev in events[i:end])
|
|
592
|
+
rows.append((i, end, "scroll",
|
|
593
|
+
f"{input_id} {total:+.1f}", f"{dt:.1f}s"))
|
|
594
|
+
i = end
|
|
595
|
+
else:
|
|
596
|
+
rows.append((i, i + 1, kind, f"{kind} {event[2:]!r}", f"{dt:.1f}s"))
|
|
597
|
+
i += 1
|
|
598
|
+
_emit_cues(rows, len(events))
|
|
599
|
+
# trailing cues after the last event (recorded in the take's tail)
|
|
600
|
+
for index in sorted(cues_at):
|
|
601
|
+
if index > len(events):
|
|
602
|
+
_emit_cues(rows, index)
|
|
603
|
+
return rows
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# editor -> command verb for the generalized view (mirrors change_value's
|
|
607
|
+
# archetype map; kept separate so the window never imports the task module).
|
|
608
|
+
_COMMAND_VERBS = {"draw_float": "drag", "draw_int": "drag",
|
|
609
|
+
"draw_str": "type", "draw_text": "type", "draw_bool": "toggle"}
|
|
610
|
+
# Cue kinds recorded off the EDIT stack: a value the user set on some
|
|
611
|
+
# target. Every one of them is a command with a target and a press -
|
|
612
|
+
# whether or not an archetype can DRIVE its value (that is what
|
|
613
|
+
# _COMMAND_VERBS decides): the conditions and the gates-only replay
|
|
614
|
+
# (open / uncover, then the tape presses) apply to all of them.
|
|
615
|
+
_EDIT_KINDS = ("Change", "SetterChange")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def cue_gesture(cue):
|
|
619
|
+
"""Which press a cue's gesture is: "header" for a window move / raise
|
|
620
|
+
(the header strip), "leaf" for everything else (the target's rect)."""
|
|
621
|
+
return "header" if cue_get(cue, "kind") in ("WindowMoveChange", "raise") else "leaf"
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def cue_press_frac(cue):
|
|
625
|
+
"""The demonstrated press as a fraction of the target's rect — the
|
|
626
|
+
cue's own press_frac, else derived from press_offset + leaf_rect."""
|
|
627
|
+
frac = cue_get(cue, "press_frac")
|
|
628
|
+
if frac:
|
|
629
|
+
return (float(frac[0]), float(frac[1]))
|
|
630
|
+
offset, rect = cue_get(cue, "press_offset"), cue_get(cue, "leaf_rect")
|
|
631
|
+
if offset and rect and rect[2] > 0 and rect[3] > 0:
|
|
632
|
+
return (float(offset[0]) / float(rect[2]), float(offset[1]) / float(rect[3]))
|
|
633
|
+
return None
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def cue_press_offset(cue):
|
|
637
|
+
"""Where the recording pressed, as an OFFSET (px) from the cue TARGET's
|
|
638
|
+
top-left — the cue's own press_offset (an effect cue: the press
|
|
639
|
+
relative to the view the effect happened on), else the press fraction
|
|
640
|
+
of its leaf_rect (an edit cue: the leaf IS the target). None without
|
|
641
|
+
geometry (a legacy cue)."""
|
|
642
|
+
offset = cue_get(cue, "press_offset")
|
|
643
|
+
if offset:
|
|
644
|
+
return (float(offset[0]), float(offset[1]))
|
|
645
|
+
rect, frac = cue_get(cue, "leaf_rect"), cue_press_frac(cue)
|
|
646
|
+
if rect and frac is not None:
|
|
647
|
+
return (frac[0] * float(rect[2]), frac[1] * float(rect[3]))
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def cue_control(cue):
|
|
652
|
+
"""The CONTROL the cue pressed, as (width, height, frac_x, frac_y) —
|
|
653
|
+
its leaf_rect's size and the press's fraction of it. This bounds a
|
|
654
|
+
precondition re-pick: a header button is the button, not the view it
|
|
655
|
+
belongs to; a leaf editor is the leaf. None without geometry."""
|
|
656
|
+
rect, frac = cue_get(cue, "leaf_rect"), cue_press_frac(cue)
|
|
657
|
+
if not rect or frac is None:
|
|
658
|
+
return None
|
|
659
|
+
width, height = float(rect[2]), float(rect[3])
|
|
660
|
+
if width <= 0 or height <= 0:
|
|
661
|
+
return None
|
|
662
|
+
return (width, height, float(frac[0]), float(frac[1]))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def cue_has_target(cue):
|
|
666
|
+
"""A cue whose gesture pressed a resolvable target — what the window
|
|
667
|
+
lists preconditions for and the replay makes hittable first."""
|
|
668
|
+
return bool(cue_get(cue, "chain")) and cue_get(cue, "kind") not in ("WindowChange",)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def move_delta(cue):
|
|
672
|
+
"""A WindowMoveChange cue's (dx, dy): its old/new window positions are
|
|
673
|
+
tuples, stored as reprs — parsed back here. None when unparseable (a
|
|
674
|
+
legacy cue): the tape then plays the drag verbatim."""
|
|
675
|
+
import ast
|
|
676
|
+
try:
|
|
677
|
+
old = ast.literal_eval(str(cue_get(cue, "old")))
|
|
678
|
+
new = ast.literal_eval(str(cue_get(cue, "new")))
|
|
679
|
+
return (float(new[0]) - float(old[0]), float(new[1]) - float(old[1]))
|
|
680
|
+
except (ValueError, SyntaxError, TypeError, IndexError):
|
|
681
|
+
return None
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _format_value(value, value_type=None):
|
|
685
|
+
"""A cue value for a label: floats short, strings quoted — unless the
|
|
686
|
+
cue says the value is NOT a str (`value_type`): a tuple / enum arrives
|
|
687
|
+
as its repr string and is shown as written, never quoted."""
|
|
688
|
+
if isinstance(value, float):
|
|
689
|
+
return f"{value:g}"
|
|
690
|
+
if isinstance(value, str):
|
|
691
|
+
return value if value_type not in (None, "str") else repr(value)
|
|
692
|
+
return str(value)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def generalized_commands(take):
|
|
696
|
+
"""The take read at the COMMAND level: one row per cue — the effects ARE
|
|
697
|
+
the generalization (events are the how, cues the what). A leaf-editor
|
|
698
|
+
cue renders as the change_value call that reproduces it; container bool
|
|
699
|
+
flips as expand/collapse; window and nav cues by their display names.
|
|
700
|
+
Rows share group_events' (start_index, end_index, kind, label, tag)
|
|
701
|
+
shape so the window's detail renderer and the replay-progress highlight
|
|
702
|
+
serve both tabs."""
|
|
703
|
+
rows = []
|
|
704
|
+
previous = 0
|
|
705
|
+
for cue in (getattr(take, "cues", None) or []):
|
|
706
|
+
at = max(cue_get(cue, "at", 0), previous)
|
|
707
|
+
kind = cue_get(cue, "kind")
|
|
708
|
+
name = cue_get(cue, "name") or "?"
|
|
709
|
+
chain = cue_get(cue, "chain") or []
|
|
710
|
+
path = "/".join(chain[-2:]) if len(chain) >= 2 else name
|
|
711
|
+
new = cue_get(cue, "new", cue_get(cue, "new_repr"))
|
|
712
|
+
anchor = cue_get(cue, "anchor")
|
|
713
|
+
tag = anchor[1] if anchor else (cue_get(cue, "stack") or "")
|
|
714
|
+
arg = None
|
|
715
|
+
if kind in _EDIT_KINDS:
|
|
716
|
+
verb = _COMMAND_VERBS.get(cue_get(cue, "editor"))
|
|
717
|
+
if verb is not None:
|
|
718
|
+
# editable command: the label stops at the first argument -
|
|
719
|
+
# the window renders the value as an edit input after it
|
|
720
|
+
# (recorded value pre-filled, override editable), a plain
|
|
721
|
+
# consumer appends _format_value(arg) + ")".
|
|
722
|
+
label = f'change_value("{path}",'
|
|
723
|
+
arg = new
|
|
724
|
+
elif cue_get(cue, "value_type") == "bool":
|
|
725
|
+
verb = "expand"
|
|
726
|
+
label = (f'expand "{path}"' if new in (True, "True")
|
|
727
|
+
else f'collapse "{path}"')
|
|
728
|
+
else:
|
|
729
|
+
verb = "set"
|
|
730
|
+
label = f"set {path} = {_format_value(new, cue_get(cue, 'value_type'))}"
|
|
731
|
+
elif kind == "WindowMoveChange":
|
|
732
|
+
delta = move_delta(cue)
|
|
733
|
+
verb = "move" # display_name is "move <window>"
|
|
734
|
+
label = (f'{name} by ({delta[0]:.0f}, {delta[1]:.0f})' if delta is not None
|
|
735
|
+
else name)
|
|
736
|
+
elif kind == "WindowChange":
|
|
737
|
+
verb, label = "window", name # "open <w>" or "close <w>"
|
|
738
|
+
elif kind == "raise":
|
|
739
|
+
verb, label = "raise", f'raise "{name}"'
|
|
740
|
+
elif kind == "scroll":
|
|
741
|
+
verb, label = "scroll", f'scroll "{name}"'
|
|
742
|
+
elif kind == "button":
|
|
743
|
+
verb, label = "button", f'press "{name}"'
|
|
744
|
+
elif kind in ("expand", "collapse"):
|
|
745
|
+
verb, label = "expand", f'{kind} "{path}"'
|
|
746
|
+
else:
|
|
747
|
+
verb, label = "goto", name
|
|
748
|
+
rows.append((previous, at, verb, label, tag, arg))
|
|
749
|
+
previous = at
|
|
750
|
+
return rows
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def auto_take_name(take):
|
|
754
|
+
"""A name from the take's own effects: the first leaf-edit cue as
|
|
755
|
+
"field → value" (falling back to the first cue's display name), plus a
|
|
756
|
+
"+N" for the remaining cues. None when there is nothing to name from."""
|
|
757
|
+
cues = getattr(take, "cues", None) or []
|
|
758
|
+
if not cues:
|
|
759
|
+
return None
|
|
760
|
+
main = next((cue for cue in cues
|
|
761
|
+
if cue_get(cue, "kind") in _EDIT_KINDS
|
|
762
|
+
and cue_get(cue, "editor") in _COMMAND_VERBS), cues[0])
|
|
763
|
+
if cue_get(main, "kind") in _EDIT_KINDS and cue_get(main, "editor") in _COMMAND_VERBS:
|
|
764
|
+
value = cue_get(main, "new", cue_get(main, "new_repr"))
|
|
765
|
+
label = f"{cue_get(main, 'name')} → {_format_value(value)}"
|
|
766
|
+
elif cue_get(main, "kind") in ("expand", "collapse", "raise", "button"):
|
|
767
|
+
label = f"{cue_get(main, 'kind')} {cue_get(main, 'name')}"
|
|
768
|
+
elif cue_get(main, "kind") in _EDIT_KINDS:
|
|
769
|
+
value = cue_get(main, "new", cue_get(main, "new_repr"))
|
|
770
|
+
label = f"{cue_get(main, 'name')} → {_format_value(value, cue_get(main, 'value_type'))}"
|
|
771
|
+
else:
|
|
772
|
+
label = str(cue_get(main, "name") or "Orchestration")
|
|
773
|
+
extra = len(cues) - 1
|
|
774
|
+
return f"{label} +{extra}" if extra > 0 else label
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def parse_argument(text, recorded):
|
|
778
|
+
"""Parse an argument edit box's text by the RECORDED value's type —
|
|
779
|
+
float/int/bool coerce (bool accepts true/false/1/0), str passes raw.
|
|
780
|
+
Returns (ok, value); a failed coercion keeps the previous value."""
|
|
781
|
+
text = text.strip() if isinstance(text, str) else text
|
|
782
|
+
try:
|
|
783
|
+
if isinstance(recorded, bool):
|
|
784
|
+
if str(text).lower() in ("true", "1", "yes", "on"):
|
|
785
|
+
return True, True
|
|
786
|
+
if str(text).lower() in ("false", "0", "no", "off"):
|
|
787
|
+
return True, False
|
|
788
|
+
return False, recorded
|
|
789
|
+
if isinstance(recorded, int):
|
|
790
|
+
return True, int(float(text))
|
|
791
|
+
if isinstance(recorded, float):
|
|
792
|
+
return True, float(text)
|
|
793
|
+
return True, str(text)
|
|
794
|
+
except (TypeError, ValueError):
|
|
795
|
+
return False, recorded
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def failure_report(failure):
|
|
799
|
+
"""Everything needed to debug a failed run, as text (the error bar's
|
|
800
|
+
copy target): the failure line, the take's commands (with overrides),
|
|
801
|
+
the grouped event list — the failed row marked — then the raw cues and
|
|
802
|
+
raw events, engine state and the tails of the undo stacks / ledger."""
|
|
803
|
+
take = failure.orchestration
|
|
804
|
+
name = str(getattr(take, "name", None) or "task")
|
|
805
|
+
where = ""
|
|
806
|
+
if failure.cue_index is not None:
|
|
807
|
+
where = f" (command {failure.cue_index + 1})"
|
|
808
|
+
elif failure.event_index is not None:
|
|
809
|
+
where = f" (event {failure.event_index})"
|
|
810
|
+
lines = [f"ORCHESTRATION FAILURE: {name}{where}",
|
|
811
|
+
f"reason: {failure.reason}",
|
|
812
|
+
f"event_index={failure.event_index} cue_index={failure.cue_index}",
|
|
813
|
+
f"status={Orchestrator.status!r} replay_speed="
|
|
814
|
+
f"{Toggles.Orchestrator.replay_speed} cue_wait_frames="
|
|
815
|
+
f"{Toggles.Orchestrator.cue_wait_frames}", ""]
|
|
816
|
+
if take is None:
|
|
817
|
+
return "\n".join(lines)
|
|
818
|
+
events = getattr(take, "events", None) or []
|
|
819
|
+
cues = getattr(take, "cues", None) or []
|
|
820
|
+
overrides = getattr(take, "overrides", None) or {}
|
|
821
|
+
fail_at = None
|
|
822
|
+
if failure.cue_index is not None and failure.cue_index < len(cues):
|
|
823
|
+
fail_at = cue_get(cues[failure.cue_index], "at", 0)
|
|
824
|
+
elif failure.event_index is not None:
|
|
825
|
+
fail_at = failure.event_index
|
|
826
|
+
lines.append(f"take: {len(events)} events, {len(cues)} cues, "
|
|
827
|
+
f"duration {getattr(take, 'duration', 0)}s, "
|
|
828
|
+
f"restore={getattr(take, 'restore_on_finish', False)}, "
|
|
829
|
+
f"overrides={overrides}")
|
|
830
|
+
lines += ["", "COMMANDS:"]
|
|
831
|
+
for ordinal, row in enumerate(generalized_commands(take)):
|
|
832
|
+
start, end, kind, label, tag, arg = (row + (None,))[:6]
|
|
833
|
+
if arg is not None:
|
|
834
|
+
value = overrides.get(str(ordinal), arg)
|
|
835
|
+
label = f"{label} {_format_value(value)})" + \
|
|
836
|
+
(" [override]" if str(ordinal) in overrides else "")
|
|
837
|
+
mark = " <<< FAILED" if ordinal == failure.cue_index else ""
|
|
838
|
+
lines.append(f" [{ordinal}] {kind:<8} {label} ({start}-{end}) {tag}{mark}")
|
|
839
|
+
if failure.cue_index is not None and failure.cue_index < len(cues):
|
|
840
|
+
failed_cue = cues[failure.cue_index]
|
|
841
|
+
if cue_has_target(failed_cue):
|
|
842
|
+
from meltygui.core.automation.value_core import describe_target
|
|
843
|
+
from meltygui.core.automation.value_core import list_preconditions
|
|
844
|
+
path = tuple(cue_get(failed_cue, "chain") or [cue_get(failed_cue, "name") or "?"])
|
|
845
|
+
gesture, frac = cue_gesture(failed_cue), cue_press_frac(failed_cue)
|
|
846
|
+
lines += ["", "TARGET (at report time — what the solver sees for the failed command):"]
|
|
847
|
+
try:
|
|
848
|
+
for field, value in describe_target(path, gesture=gesture, press_frac=frac).items():
|
|
849
|
+
lines.append(f" {field}: {value!r}")
|
|
850
|
+
for row in list_preconditions(path, gesture=gesture, press_frac=frac):
|
|
851
|
+
lines.append(f" precondition: {row['kind']} '{row['node']}' — {row['label']}"
|
|
852
|
+
f" fixes: {[label for _c, label in row['fixes']]}")
|
|
853
|
+
except Exception as error:
|
|
854
|
+
lines.append(f" (could not describe: {type(error).__name__}: {error})")
|
|
855
|
+
lines += ["", "EVENTS (grouped):"]
|
|
856
|
+
for start, end, kind, label, tag in group_events(events, cues):
|
|
857
|
+
if kind == "cue":
|
|
858
|
+
hit = fail_at is not None and start == fail_at
|
|
859
|
+
else:
|
|
860
|
+
hit = fail_at is not None and start <= fail_at < max(end, start + 1)
|
|
861
|
+
lines.append(f" {start:>4}-{end:<4} {kind:<7} {label} {tag}"
|
|
862
|
+
+ (" <<< FAILED" if hit else ""))
|
|
863
|
+
lines += ["", "CUES (raw):"]
|
|
864
|
+
for index, cue in enumerate(cues):
|
|
865
|
+
lines.append(f" [{index}] {cue!r}")
|
|
866
|
+
lines += ["", "RAW EVENTS:"]
|
|
867
|
+
for index, event in enumerate(events):
|
|
868
|
+
lines.append(f" {index:>4} {event!r}"
|
|
869
|
+
+ (" <<< stopped here" if index == failure.event_index else ""))
|
|
870
|
+
lines += ["", "UNDO STACKS (newest last, tail):"]
|
|
871
|
+
for stack_name, stack in _stacks().items():
|
|
872
|
+
tail = list(stack.history)[-12:]
|
|
873
|
+
lines.append(f" {stack_name}: {len(stack.history)} entries")
|
|
874
|
+
for change in tail:
|
|
875
|
+
lines.append(f" gid={change.group_id} {type(change).__name__} "
|
|
876
|
+
f"'{change.display_name}' {_short_repr(change.old)} -> "
|
|
877
|
+
f"{_short_repr(change.new)}")
|
|
878
|
+
lines.append(f" effects: {len(EffectLedger.entries)} entries")
|
|
879
|
+
for entry in list(EffectLedger.entries)[-12:]:
|
|
880
|
+
lines.append(f" seq={entry.seq} {entry.kind} '{entry.name}' frame={entry.frame}")
|
|
881
|
+
# the screen's window order at report time - what the `behind` /
|
|
882
|
+
# `obscured` preconditions read (back → front; `root` = a standalone
|
|
883
|
+
# top-level window, the only kind that can be "the top window")
|
|
884
|
+
lines += ["", "PAINT ORDER (back → front, at report time):"]
|
|
885
|
+
for window in list(getattr(Melty, "paint_ordered_ds", None) or []):
|
|
886
|
+
parent = getattr(window, "parent_window", None)
|
|
887
|
+
lines.append(
|
|
888
|
+
f" {str(getattr(window, 'name', '?')).split('##')[0]:<28} "
|
|
889
|
+
f"layer={getattr(window, 'layer', None)} "
|
|
890
|
+
f"{'root' if parent is None or parent is window else 'nested'} "
|
|
891
|
+
f"closable={getattr(window, 'closable', None)} "
|
|
892
|
+
f"closed={getattr(window, 'closed', None)}/{getattr(window, 'abs_closed', None)} "
|
|
893
|
+
f"rect=({getattr(window, 'abs_left', None)}, {getattr(window, 'abs_top', None)}, "
|
|
894
|
+
f"{getattr(window, 'width', None)}, {getattr(window, 'height', None)})")
|
|
895
|
+
lines += ["", "ENGINE TRACE (per pump, state at pump start — real_io_* is the PHYSICAL "
|
|
896
|
+
"mouse, `virtual` the engine's; injected = previous pump; target.blit_last = "
|
|
897
|
+
"its tile came from the blit cache on the frame just rendered):"]
|
|
898
|
+
for row in list(Orchestrator._trace):
|
|
899
|
+
row = dict(row)
|
|
900
|
+
injected = row.pop("injected", [])
|
|
901
|
+
target = row.pop("target", None)
|
|
902
|
+
lines.append(" " + " ".join(f"{k}={v}" for k, v in row.items()))
|
|
903
|
+
if target is not None:
|
|
904
|
+
lines.append(f" target: {target}")
|
|
905
|
+
if injected:
|
|
906
|
+
lines.append(f" injected: {injected}")
|
|
907
|
+
return "\n".join(lines)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
class Orchestrator:
|
|
911
|
+
"""The record/replay engine. All state in class attributes (hotswap keeps
|
|
912
|
+
live values, same as UndoManager). Exactly one of `recording` /
|
|
913
|
+
`replaying` is non-None at a time; `_restore_steps` runs after a replay
|
|
914
|
+
finishes with restore checked."""
|
|
915
|
+
|
|
916
|
+
recording = None # Orchestration being recorded into
|
|
917
|
+
replaying = None # Orchestration being replayed
|
|
918
|
+
status = "" # one-line state for the window's footer
|
|
919
|
+
|
|
920
|
+
_record_t0 = 0.0
|
|
921
|
+
_record_marks = {} # stack name -> group id at record start / last cue sweep
|
|
922
|
+
_record_base_marks = {} # stack name -> group id at record START (restore target;
|
|
923
|
+
# _record_marks advances with every cue sweep)
|
|
924
|
+
_relativized_upto = 0 # events before this index are claimed by a cue's anchor
|
|
925
|
+
_cue_changes = [] # [(cue index, Change)] cut this recording: their `new` tracks the coalescing edit
|
|
926
|
+
_last_edit_claim = 0 # event index the previous EDIT cue's gesture ended at: the next gesture starts after
|
|
927
|
+
_gesture_cue = None # cue claiming the IN-FLIGHT gesture's remaining events
|
|
928
|
+
_gesture_buttons = set() # buttons still held from that gesture
|
|
929
|
+
_gesture_end_pending = False
|
|
930
|
+
_last_press_window = None # window under the last recorded press, AT press time
|
|
931
|
+
_last_press_xy = None # that press's absolute coordinates
|
|
932
|
+
|
|
933
|
+
_replay_t0 = 0.0
|
|
934
|
+
_replay_index = 0 # next event to inject
|
|
935
|
+
_replay_marks = {} # stack name -> group id at replay start (restore + restore baseline)
|
|
936
|
+
_cue_cursor = 0 # next cue (index into orch.cues) not yet armed
|
|
937
|
+
_cue_pending = None # armed cue awaiting verification
|
|
938
|
+
_cue_wait = 0
|
|
939
|
+
_cue_corrected = False
|
|
940
|
+
_matched_ids = set() # id(change) of live changes already claimed by a cue
|
|
941
|
+
# Cue indices whose failed verification already ran the PRECONDITION
|
|
942
|
+
# correction (the window's own list, satisfied then the gesture
|
|
943
|
+
# replayed): a second miss of the same cue aborts instead of looping.
|
|
944
|
+
_precondition_corrected = {} # cue_index -> the task's attempts log
|
|
945
|
+
_anchor_cache = {} # cue_index -> (left, top): resolved anchor origins this replay
|
|
946
|
+
_replay_end = None # exclusive event bound of a single replay run (None means full)
|
|
947
|
+
_replay_partial = False # partial run: restore_on_finish is skipped
|
|
948
|
+
# The most recent failure, shown by the window (red row highlight + the
|
|
949
|
+
# header error bar): orchestration ref, reason, event_index (where the
|
|
950
|
+
# replay stopped), cue_index (the cue that failed / the one that
|
|
951
|
+
# aborted). Cleared when that take later finishes successfully, or by
|
|
952
|
+
# the error bar's dismiss chip.
|
|
953
|
+
last_failure = None
|
|
954
|
+
# The most recent success (green row wash + "- ok" tag). A failure of
|
|
955
|
+
# the same take displaces it, and vice versa.
|
|
956
|
+
last_success = None
|
|
957
|
+
_click_seq = 0 # unique keys for click-ripple emphasis notes
|
|
958
|
+
# Press settling. The handler dispatches a press against the hover set
|
|
959
|
+
# the PREVIOUS render registered, and that render showed the cursor where
|
|
960
|
+
# stamp_io put it a frame earlier - so a press injected in the same
|
|
961
|
+
# pump as the move lands wherever the cursor WAS (the real pointer is
|
|
962
|
+
# the play button: it grabbed the orchestrator window and the servo's
|
|
963
|
+
# moves dragged it across the screen). A press is only injected when
|
|
964
|
+
# SETTLE_PUMPS pumps have passed since the last injected move (move →
|
|
965
|
+
# stamp_io → render → hover → press dispatches), and a replay
|
|
966
|
+
# whose first event is a press gets a move to that spot first.
|
|
967
|
+
SETTLE_PUMPS = 2
|
|
968
|
+
_pump_count = 0
|
|
969
|
+
_last_move_pump = -10
|
|
970
|
+
_cursor_settled = False # a move has been injected since play/submit
|
|
971
|
+
# Continuous mouse (Toggles.Orchestrator.continuous_mouse): an injected
|
|
972
|
+
# move/press far from the virtual mouse is queued behind an eased glide
|
|
973
|
+
# (one step per pump), later injections queue FIFO behind it - so a
|
|
974
|
+
# reused click (a gate fragment, an offset click, a cue resync after
|
|
975
|
+
# a remap) TRAVELS to its spot instead of snapping, which is also the
|
|
976
|
+
# frames the hover / press-ready machinery needs. Items: ("glide", x, y)
|
|
977
|
+
# or a raw event tuple.
|
|
978
|
+
_glide_queue = collections.deque()
|
|
979
|
+
PRESS_JUMP_PX = 24.0 # a press this far from the cursor glides there first, mouse up
|
|
980
|
+
_deferred_press = None # (input_id, x, y) - correction presses once settled
|
|
981
|
+
_deferred_release = None # the above's release, the pump AFTER its press
|
|
982
|
+
# Value remaps: the take's overrides applied DURING replay. A gesture
|
|
983
|
+
# whose cue value is overridden plays as the override instead of its
|
|
984
|
+
# recorded drag - the same kind of remap anchoring is (positions follow
|
|
985
|
+
# the window; here the drag length follows the value) - and the tape
|
|
986
|
+
# resumes after that gesture's release. {down_index: (up_index,
|
|
987
|
+
# cue_index, value)}; `_remap` is the running (task, generator, ...).
|
|
988
|
+
_remaps = {}
|
|
989
|
+
_remap = None
|
|
990
|
+
_remap_universe = None
|
|
991
|
+
# Retargeting a gates-only remap: the tape's gesture is injected where
|
|
992
|
+
# its TARGET is now, not where it was recorded - ((dx, dy), first event
|
|
993
|
+
# index, last event index) added to the events of that span by
|
|
994
|
+
# _event_xy`. A resolved target (the cue's chain) beats the recorded
|
|
995
|
+
# spot: reordering a list moved the item, and a verbatim press landed
|
|
996
|
+
# on whatever now sat at its old index (Lukas 09-01).
|
|
997
|
+
_remap_shift = None
|
|
998
|
+
# Anchor inheritance: an event recorded with no anchor of its own (a
|
|
999
|
+
# stretch no cue claimed - e.g. before an effect cue that had no press)
|
|
1000
|
+
# gets the anchor of the stretch BEFORE it (or, for a take's leading
|
|
1001
|
+
# events, the first anchored stretch after), so the cursor keeps ONE
|
|
1002
|
+
# window offset instead of jumping out to stale absolute coordinates
|
|
1003
|
+
# and back. {event_index: cue_index}, built by play().
|
|
1004
|
+
_inherited_anchor = {}
|
|
1005
|
+
_injecting_index = None
|
|
1006
|
+
# REAL mouse buttons currently held (tracked from the tap, always: the
|
|
1007
|
+
# play click's own press is in there when a task arms). Their releases
|
|
1008
|
+
# are ALWAYS delivered, and no virtual press is injected while one is
|
|
1009
|
+
# held: a physical click takes ~100 ms to release, the servo's click
|
|
1010
|
+
# landed inside that window, the muted real release left the real
|
|
1011
|
+
# press's drag session (the orchestrator window grabbed the pointer)
|
|
1012
|
+
# alive, and the servo's moves dragged it across the screen.
|
|
1013
|
+
_real_down = set()
|
|
1014
|
+
# Per-pump engine trace while recording (last 90 pumps) - dumped into the
|
|
1015
|
+
# failure reason as ENGINE TRACE so a copied report shows which link of
|
|
1016
|
+
# the injection chain (io stamp → imgui hover → meltygui hover → handler
|
|
1017
|
+
# dispatch → cue activation) broke.
|
|
1018
|
+
_trace = collections.deque(maxlen=600)
|
|
1019
|
+
_trace_injected = [] # events injected during the current pump
|
|
1020
|
+
_last_click = None # (input_id, x, y) of the last injected press
|
|
1021
|
+
_injecting = False # True while pump feeds the handler (tap lets those through)
|
|
1022
|
+
_restore_steps = [] # [(stack_name, mark_gid), ...] still to unwind
|
|
1023
|
+
_pending_release = set() # buttons held at record start (the Record click)
|
|
1024
|
+
_task = None # active ValueTask (change_value) — generator, stepped per frame
|
|
1025
|
+
# Preconditions per value command, as the window shows them: the body
|
|
1026
|
+
# names the (orchestration key, command ordinal) rows it has on screen
|
|
1027
|
+
# (`_precondition_watch`, rebuilt on every repaint of the window) and
|
|
1028
|
+
# orchestrator_sync re-lists them every precondition_refresh_frames
|
|
1029
|
+
# (`_preconditions`: key -> change_value.list_preconditions rows).
|
|
1030
|
+
_precondition_watch = set()
|
|
1031
|
+
_preconditions = {}
|
|
1032
|
+
_precondition_frame = -10 ** 9
|
|
1033
|
+
|
|
1034
|
+
@classmethod
|
|
1035
|
+
def refresh_preconditions(cls, store):
|
|
1036
|
+
"""Re-list the watched commands' preconditions (from orchestrator_sync).
|
|
1037
|
+
Returns True when any row changed."""
|
|
1038
|
+
from meltygui.core.automation.value_core import list_preconditions
|
|
1039
|
+
changed = False
|
|
1040
|
+
fresh = {}
|
|
1041
|
+
for watch_key in list(cls._precondition_watch):
|
|
1042
|
+
orchestration_key, ordinal = watch_key
|
|
1043
|
+
orchestration = store.orchestrations.get(orchestration_key) if store else None
|
|
1044
|
+
cues = getattr(orchestration, "cues", None) or []
|
|
1045
|
+
if orchestration is None or not (0 <= ordinal < len(cues)):
|
|
1046
|
+
continue
|
|
1047
|
+
cue = cues[ordinal]
|
|
1048
|
+
if not cue_has_target(cue):
|
|
1049
|
+
continue
|
|
1050
|
+
path = tuple(cue_get(cue, "chain") or [cue_get(cue, "name") or "?"])
|
|
1051
|
+
gesture, frac = cue_gesture(cue), cue_press_frac(cue)
|
|
1052
|
+
try:
|
|
1053
|
+
rows = list_preconditions(path, gesture=gesture, press_frac=frac)
|
|
1054
|
+
if not rows:
|
|
1055
|
+
# the "target hittable" row explains its verdict: the
|
|
1056
|
+
# press point the solver will use and the window it
|
|
1057
|
+
# finds in front there - a wrong leaf / point / front
|
|
1058
|
+
# window is visible at a glance instead of a silent pass
|
|
1059
|
+
from meltygui.core.automation.value_core import describe_target
|
|
1060
|
+
facts = describe_target(path, gesture=gesture, press_frac=frac)
|
|
1061
|
+
rows = [{"key": ("hittable", facts.get("resolved") or "?"), "kind": "hittable",
|
|
1062
|
+
"node": facts.get("resolved") or "?",
|
|
1063
|
+
"label": f"target hittable — {facts.get('resolved')!s} "
|
|
1064
|
+
f"({facts.get('editor')}) at {facts.get('point')}, "
|
|
1065
|
+
f"front: {facts.get('front')!s}",
|
|
1066
|
+
"fixes": []}]
|
|
1067
|
+
except Exception as error: # a half-built tree mid-frame: show it, never raise
|
|
1068
|
+
rows = [{"key": ("error", str(error)[:60]), "kind": "error", "node": "",
|
|
1069
|
+
"label": f"could not list: {type(error).__name__}: {error}"[:160],
|
|
1070
|
+
"fixes": []}]
|
|
1071
|
+
fresh[watch_key] = rows
|
|
1072
|
+
previous = cls._preconditions.get(watch_key)
|
|
1073
|
+
if previous is None or [r["key"] for r in previous] != [r["key"] for r in rows] \
|
|
1074
|
+
or [r["label"] for r in previous] != [r["label"] for r in rows]:
|
|
1075
|
+
changed = True
|
|
1076
|
+
if set(fresh) != set(cls._preconditions):
|
|
1077
|
+
changed = True
|
|
1078
|
+
cls._preconditions = fresh
|
|
1079
|
+
return changed
|
|
1080
|
+
|
|
1081
|
+
# Virtual input state stamped over imgui's io while replaying.
|
|
1082
|
+
_virtual_x = 0.0
|
|
1083
|
+
_virtual_y = 0.0
|
|
1084
|
+
_virtual_buttons = {} # input_id -> True while virtually depressed
|
|
1085
|
+
_virtual_mods = 0 # glfw mod state from the last injected key
|
|
1086
|
+
_virtual_wheel = 0.0 # accumulated scroll for io.mouse_wheel this frame
|
|
1087
|
+
_virtual_chars = [] # codepoints queued for io.add_input_char
|
|
1088
|
+
|
|
1089
|
+
# Stamped by the window body each frame: (left, top, right, bottom) rect
|
|
1090
|
+
# used to trim the stop click off a take's tail.
|
|
1091
|
+
window_rect = None
|
|
1092
|
+
|
|
1093
|
+
# ── the funnel ───────────────────────────────────────────────────────
|
|
1094
|
+
|
|
1095
|
+
@classmethod
|
|
1096
|
+
def tap(cls, kind, *args):
|
|
1097
|
+
"""input_handler's input_tap target: sees every REAL input event.
|
|
1098
|
+
Returns True to consume it (replay/restore mute)."""
|
|
1099
|
+
if cls._injecting:
|
|
1100
|
+
return False # our own injection - let it through
|
|
1101
|
+
real_button = args[0] if (kind in ("down", "up") and args
|
|
1102
|
+
and args[0] in _IMGUI_BUTTON) else None
|
|
1103
|
+
if real_button is not None:
|
|
1104
|
+
if kind == "down":
|
|
1105
|
+
cls._real_down.add(real_button)
|
|
1106
|
+
else:
|
|
1107
|
+
was_real = real_button in cls._real_down
|
|
1108
|
+
cls._real_down.discard(real_button)
|
|
1109
|
+
if was_real and not cls._real_down:
|
|
1110
|
+
# Hover-driven tile invalidation is suppressed while a
|
|
1111
|
+
# button is held (Melty.on_drag), so the settle clock
|
|
1112
|
+
# restarts at a real release: only from here can the
|
|
1113
|
+
# target's cursor leave the window and report hover.
|
|
1114
|
+
cls._last_move_pump = cls._pump_count
|
|
1115
|
+
if cls.replaying is not None or cls._restore_steps or cls._task is not None:
|
|
1116
|
+
if kind == "key" and args and args[0] == glfw.KEY_ESCAPE:
|
|
1117
|
+
cls.abort("Esc")
|
|
1118
|
+
# A REAL release always reaches the handler - the play click's
|
|
1119
|
+
# own release lands after the take armed, and muting it leaves
|
|
1120
|
+
# its mouse capture alive under the pointer. (A release for a
|
|
1121
|
+
# button the real press put down is the backend's auto-release
|
|
1122
|
+
# guard reacting to the VIRTUALLY held button: that one stays
|
|
1123
|
+
# muted or it would cut its injected drag.)
|
|
1124
|
+
if kind == "up" and real_button is not None and was_real:
|
|
1125
|
+
return False
|
|
1126
|
+
return True # mute real input while driving
|
|
1127
|
+
take = cls.recording
|
|
1128
|
+
if take is not None:
|
|
1129
|
+
# The click that pressed Record is still HELD when recording arms:
|
|
1130
|
+
# the drag/moves and release belong to the arming gesture, not the
|
|
1131
|
+
# take (recorded, but replayed as an immediate click-drag).
|
|
1132
|
+
# Swallow moves and those buttons' releases until every armed-down
|
|
1133
|
+
# button is up - keys/chars typed meanwhile are real content.
|
|
1134
|
+
if cls._pending_release:
|
|
1135
|
+
if kind == "up" and args and args[0] in cls._pending_release:
|
|
1136
|
+
cls._pending_release.discard(args[0])
|
|
1137
|
+
return False
|
|
1138
|
+
if kind == "move":
|
|
1139
|
+
return False
|
|
1140
|
+
now = round(time.monotonic() - cls._record_t0, 4)
|
|
1141
|
+
event = (now, kind) + tuple(args)
|
|
1142
|
+
if kind == "down" and args and args[0] in _IMGUI_BUTTON:
|
|
1143
|
+
# The click's target window, right NOW - this frame's BVH
|
|
1144
|
+
# still holds the pre-click world, before the effect the
|
|
1145
|
+
# press triggers (window raise) restamps it. The effect cue's
|
|
1146
|
+
# anchor reads this stash, skipping a cue-time re-resolve.
|
|
1147
|
+
cls._last_press_window = _window_under(args[1], args[2])
|
|
1148
|
+
cls._last_press_xy = (args[1], args[2])
|
|
1149
|
+
if kind in ("move", "down", "up"):
|
|
1150
|
+
# In-flight gesture tail: a cue cut while a button was HELD
|
|
1151
|
+
# claims the REST of that gesture as it happens (see
|
|
1152
|
+
# poll_recording_into) - the release and its moves relativize
|
|
1153
|
+
# to the same anchor as the press, so a gesture is never
|
|
1154
|
+
# split across reference frames.
|
|
1155
|
+
event = cls._retag_gesture_event(take, event)
|
|
1156
|
+
if kind == "move" and take.events:
|
|
1157
|
+
last = take.events[-1]
|
|
1158
|
+
# jitter throttle - only as a move in the SAME frame
|
|
1159
|
+
# (absolute vs absolute, or relative with the same anchor)
|
|
1160
|
+
if (last[1] == "move" and len(last) == len(event)
|
|
1161
|
+
and (len(event) == 4 or last[4] == event[4])
|
|
1162
|
+
and abs(event[2] - last[2]) < Toggles.Orchestrator.move_sample_min_px
|
|
1163
|
+
and abs(event[3] - last[3]) < Toggles.Orchestrator.move_sample_min_px):
|
|
1164
|
+
return False # sub-pixel jitter - skip
|
|
1165
|
+
take.events.append(event)
|
|
1166
|
+
if cls._gesture_end_pending:
|
|
1167
|
+
# The gesture's last button released: its span is fully
|
|
1168
|
+
# claimed - the NEXT movement starts a fresh (absolute)
|
|
1169
|
+
# stretch for the next cue to claim retroactively
|
|
1170
|
+
cls._gesture_end_pending = False
|
|
1171
|
+
cls._gesture_cue = None
|
|
1172
|
+
cls._relativized_upto = len(take.events)
|
|
1173
|
+
return False
|
|
1174
|
+
|
|
1175
|
+
@classmethod
|
|
1176
|
+
def _retag_gesture_event(cls, take, event):
|
|
1177
|
+
"""While a gesture tail is claimed (cue cut mid-hold): relativize the
|
|
1178
|
+
incoming mouse event against the claiming cue's anchor and track the
|
|
1179
|
+
held buttons; the release of the last one ends the claim."""
|
|
1180
|
+
if cls._gesture_cue is None or cls._gesture_cue >= len(take.cues):
|
|
1181
|
+
return event
|
|
1182
|
+
anchor = cue_get(take.cues[cls._gesture_cue], "anchor")
|
|
1183
|
+
if anchor is None:
|
|
1184
|
+
cls._gesture_cue = None
|
|
1185
|
+
return event
|
|
1186
|
+
anchor_x, anchor_y = anchor[2], anchor[3]
|
|
1187
|
+
if event[1] == "move":
|
|
1188
|
+
return (event[0], "move", event[2] - anchor_x, event[3] - anchor_y,
|
|
1189
|
+
cls._gesture_cue)
|
|
1190
|
+
input_id = event[2]
|
|
1191
|
+
if event[1] == "down" and input_id in _IMGUI_BUTTON:
|
|
1192
|
+
cls._gesture_buttons.add(input_id)
|
|
1193
|
+
elif event[1] == "up":
|
|
1194
|
+
cls._gesture_buttons.discard(input_id)
|
|
1195
|
+
if not cls._gesture_buttons:
|
|
1196
|
+
cls._gesture_end_pending = True
|
|
1197
|
+
return (event[0], event[1], input_id, event[3] - anchor_x,
|
|
1198
|
+
event[4] - anchor_y, cls._gesture_cue)
|
|
1199
|
+
|
|
1200
|
+
# ── recording ────────────────────────────────────────────────────────
|
|
1201
|
+
|
|
1202
|
+
@classmethod
|
|
1203
|
+
def start_recording(cls, orchestration):
|
|
1204
|
+
if cls.replaying is not None or cls._restore_steps:
|
|
1205
|
+
return
|
|
1206
|
+
orchestration.events = []
|
|
1207
|
+
orchestration.cues = []
|
|
1208
|
+
orchestration.duration = 0.0
|
|
1209
|
+
cls.recording = orchestration
|
|
1210
|
+
cls._record_t0 = time.monotonic()
|
|
1211
|
+
# Buttons already down when recording arms (the Record click itself)
|
|
1212
|
+
# - their remaining drag is swallowed by the tap until released.
|
|
1213
|
+
handler = Melty.event_handler
|
|
1214
|
+
cls._pending_release = {input_id for input_id in _IMGUI_BUTTON
|
|
1215
|
+
if handler is not None
|
|
1216
|
+
and getattr(handler, "is_down", None)
|
|
1217
|
+
and handler.is_down(input_id)}
|
|
1218
|
+
cls._record_marks = {name: stack._next_group_id
|
|
1219
|
+
for name, stack in _stacks().items()}
|
|
1220
|
+
cls._record_marks["effects"] = EffectLedger.next_seq
|
|
1221
|
+
cls._record_base_marks = dict(cls._record_marks)
|
|
1222
|
+
cls._cue_changes = []
|
|
1223
|
+
cls._last_edit_claim = 0
|
|
1224
|
+
cls._relativized_upto = 0
|
|
1225
|
+
cls._gesture_cue = None
|
|
1226
|
+
cls._gesture_buttons = set()
|
|
1227
|
+
cls._gesture_end_pending = False
|
|
1228
|
+
cls._last_press_window = None
|
|
1229
|
+
cls.status = "recording"
|
|
1230
|
+
request_render()
|
|
1231
|
+
|
|
1232
|
+
@classmethod
|
|
1233
|
+
def stop_recording(cls, via_hotkey=False):
|
|
1234
|
+
take = cls.recording
|
|
1235
|
+
cls.recording = None
|
|
1236
|
+
cls._pending_release = set()
|
|
1237
|
+
cls._gesture_cue = None
|
|
1238
|
+
cls._gesture_buttons = set()
|
|
1239
|
+
cls._gesture_end_pending = False
|
|
1240
|
+
cls._last_press_window = None
|
|
1241
|
+
cls.status = ""
|
|
1242
|
+
if take is None:
|
|
1243
|
+
return
|
|
1244
|
+
cls.poll_recording_into(take) # cut cues one last time
|
|
1245
|
+
cls._cue_changes = []
|
|
1246
|
+
if via_hotkey:
|
|
1247
|
+
cls._trim_hotkey_tail(take)
|
|
1248
|
+
else:
|
|
1249
|
+
cls._trim_window_tail(take)
|
|
1250
|
+
# Leading "up" events are the release of the click that pressed
|
|
1251
|
+
# Record - they belong to the button gesture, not the take.
|
|
1252
|
+
while take.events and take.events[0][1] == "up":
|
|
1253
|
+
take.events.pop(0)
|
|
1254
|
+
take.duration = round(take.events[-1][0], 2) if take.events else 0.0
|
|
1255
|
+
# Auto-name from the take's events - a user-given name is never
|
|
1256
|
+
# overwritten (custom_name, set by the window's rename commit).
|
|
1257
|
+
if not getattr(take, "custom_name", False):
|
|
1258
|
+
generated = auto_take_name(take)
|
|
1259
|
+
if generated:
|
|
1260
|
+
take.name = generated
|
|
1261
|
+
# A take recorded WITH restore resets the app right here: everything
|
|
1262
|
+
# the recording session put on the undo stacks (edits, window
|
|
1263
|
+
# opens/closes, window moves) unwinds back to the session-start marks
|
|
1264
|
+
# - same history as the post-replay restore, so what you see after
|
|
1265
|
+
# Stop is exactly what a subsequent replay will leave behind.
|
|
1266
|
+
if getattr(take, "restore_on_finish", False):
|
|
1267
|
+
cls._restore_steps = [(name, cls._record_base_marks.get(name, 0))
|
|
1268
|
+
for name in _stacks()]
|
|
1269
|
+
cls.status = "restoring"
|
|
1270
|
+
request_render()
|
|
1271
|
+
|
|
1272
|
+
@classmethod
|
|
1273
|
+
def poll_recording(cls):
|
|
1274
|
+
"""Once per frame from orchestrator_sync: cut a CUE for every new
|
|
1275
|
+
undo GROUP that appeared since the last sweep."""
|
|
1276
|
+
if cls.recording is not None:
|
|
1277
|
+
cls.poll_recording_into(cls.recording)
|
|
1278
|
+
|
|
1279
|
+
@classmethod
|
|
1280
|
+
def poll_recording_into(cls, take):
|
|
1281
|
+
# A cue is cut on the FIRST frame its Change exists: the gesture
|
|
1282
|
+
# goes on (a drag coalesces per frame, a command run per keystroke)
|
|
1283
|
+
# but the cue's value must be where the gesture ENDED - the endpoint
|
|
1284
|
+
# of the demonstration, what a replay drives to - not the first
|
|
1285
|
+
# tick (ranked cues read -87 for a drag that went further, 09-01).
|
|
1286
|
+
for cue_index, change in cls._cue_changes:
|
|
1287
|
+
if not (0 <= cue_index < len(take.cues)) or not isinstance(take.cues[cue_index], dict):
|
|
1288
|
+
continue
|
|
1289
|
+
cue = take.cues[cue_index]
|
|
1290
|
+
new = _primitive_or_repr(change.new)
|
|
1291
|
+
if new != cue.get("new"):
|
|
1292
|
+
cue["new"] = new
|
|
1293
|
+
cue["new_repr"] = _short_repr(change.new)
|
|
1294
|
+
cue["value_type"] = type(change.new).__name__
|
|
1295
|
+
for name, stack in _stacks().items():
|
|
1296
|
+
mark = cls._record_marks.get(name, 0)
|
|
1297
|
+
new_groups = {}
|
|
1298
|
+
for change in stack.history:
|
|
1299
|
+
if change.group_id > mark:
|
|
1300
|
+
# first change of each group anchors the cue
|
|
1301
|
+
new_groups.setdefault(change.group_id, change)
|
|
1302
|
+
for gid in sorted(new_groups):
|
|
1303
|
+
change = new_groups[gid]
|
|
1304
|
+
kind = type(change).__name__
|
|
1305
|
+
if kind == "CaretChange":
|
|
1306
|
+
continue # caret changes replay implicitly - pure noise as cues
|
|
1307
|
+
take.cues.append(make_cue(change, name, len(take.events), take,
|
|
1308
|
+
press_window=cls._last_press_window,
|
|
1309
|
+
since=cls._last_edit_claim if name == "edits" else 0))
|
|
1310
|
+
if name == "edits":
|
|
1311
|
+
cls._cue_changes.append((len(take.cues) - 1, change))
|
|
1312
|
+
cls._last_edit_claim = len(take.events)
|
|
1313
|
+
# The cue's parent window re-references the mouse events that
|
|
1314
|
+
# LED to it: absolute → window-relative, so replay can follow
|
|
1315
|
+
# the window wherever it sits now. Events after the last cue
|
|
1316
|
+
# keep absolute coordinates - the fallback.
|
|
1317
|
+
cls._relativize_events(take, len(take.events), len(take.cues) - 1)
|
|
1318
|
+
cls._claim_open_gesture(take)
|
|
1319
|
+
mark = max(mark, gid)
|
|
1320
|
+
cls._record_marks[name] = max(cls._record_marks.get(name, 0), mark)
|
|
1321
|
+
# ---- effects ledger sweep (the third cue source) ----
|
|
1322
|
+
effects_mark = cls._record_marks.get("effects", 0)
|
|
1323
|
+
for entry in list(EffectLedger.entries):
|
|
1324
|
+
if entry.seq <= effects_mark:
|
|
1325
|
+
continue
|
|
1326
|
+
effects_mark = entry.seq
|
|
1327
|
+
# A raise is a cue only if the press was explicit - on a
|
|
1328
|
+
# header band, or outside the raised window (dock row, another
|
|
1329
|
+
# window's control). A body click's auto-raise is not one.
|
|
1330
|
+
if (entry.kind == "raise"
|
|
1331
|
+
and Toggles.Orchestrator.raise_cue_needs_explicit_press
|
|
1332
|
+
and _body_press_raise(entry.draw_state, cls._last_press_xy,
|
|
1333
|
+
cls._last_press_window)):
|
|
1334
|
+
continue
|
|
1335
|
+
# consecutive-duplicate entries: press-raise cascade (every click
|
|
1336
|
+
# inside a window re-raises it in some paths) folds to one cue
|
|
1337
|
+
if take.cues:
|
|
1338
|
+
last = take.cues[-1]
|
|
1339
|
+
if (cue_get(last, "stack") == "effects"
|
|
1340
|
+
and cue_get(last, "kind") == entry.kind
|
|
1341
|
+
and cue_get(last, "name") == entry.name):
|
|
1342
|
+
continue
|
|
1343
|
+
take.cues.append(make_effect_cue(entry, take,
|
|
1344
|
+
press_window=cls._last_press_window))
|
|
1345
|
+
cls._relativize_events(take, len(take.events), len(take.cues) - 1)
|
|
1346
|
+
cls._claim_open_gesture(take)
|
|
1347
|
+
cls._record_marks["effects"] = max(cls._record_marks.get("effects", 0),
|
|
1348
|
+
effects_mark)
|
|
1349
|
+
|
|
1350
|
+
@classmethod
|
|
1351
|
+
def _claim_open_gesture(cls, take):
|
|
1352
|
+
"""A cue cut MID-GESTURE (a button still held — the effect fired on
|
|
1353
|
+
the press, e.g. WindowChange / a raise, or mid-drag) claims the rest
|
|
1354
|
+
of that gesture too: the release and its moves relativize to the
|
|
1355
|
+
same anchor as they arrive (tap → _retag_gesture_event), so a
|
|
1356
|
+
gesture is never split across reference frames and the next
|
|
1357
|
+
movement re-anchors fresh."""
|
|
1358
|
+
held = set()
|
|
1359
|
+
for event in take.events:
|
|
1360
|
+
if event[1] == "down" and event[2] in _IMGUI_BUTTON:
|
|
1361
|
+
held.add(event[2])
|
|
1362
|
+
elif event[1] == "up":
|
|
1363
|
+
held.discard(event[2])
|
|
1364
|
+
if held and cue_get(take.cues[-1], "anchor") is not None:
|
|
1365
|
+
cls._gesture_cue = len(take.cues) - 1
|
|
1366
|
+
cls._gesture_buttons = held
|
|
1367
|
+
cls._gesture_end_pending = False
|
|
1368
|
+
|
|
1369
|
+
@classmethod
|
|
1370
|
+
def _relativize_events(cls, take, upto_index, cue_index):
|
|
1371
|
+
"""Rewrite the mouse events in [_relativized_upto, upto_index) as
|
|
1372
|
+
window-relative against cue `cue_index`'s anchor: a trailing
|
|
1373
|
+
cue-index element marks the format —
|
|
1374
|
+
move: (dt, "move", x, y) → (dt, "move", rx, ry, cue_index)
|
|
1375
|
+
down/up: (dt, kind, id, x, y) → (dt, kind, id, rx, ry, cue_index)
|
|
1376
|
+
No anchor on the cue → the range stays absolute (and stays claimed,
|
|
1377
|
+
so a later cue can't re-reference another window's events)."""
|
|
1378
|
+
anchor = cue_get(take.cues[cue_index], "anchor")
|
|
1379
|
+
start = cls._relativized_upto
|
|
1380
|
+
cls._relativized_upto = max(cls._relativized_upto, upto_index)
|
|
1381
|
+
if anchor is None:
|
|
1382
|
+
return
|
|
1383
|
+
_tile, _name, anchor_x, anchor_y = anchor
|
|
1384
|
+
for i in range(start, min(upto_index, len(take.events))):
|
|
1385
|
+
event = take.events[i]
|
|
1386
|
+
if event[1] == "move" and len(event) == 4:
|
|
1387
|
+
take.events[i] = (event[0], "move", event[2] - anchor_x,
|
|
1388
|
+
event[3] - anchor_y, cue_index)
|
|
1389
|
+
elif event[1] in ("down", "up") and len(event) == 5:
|
|
1390
|
+
take.events[i] = (event[0], event[1], event[2],
|
|
1391
|
+
event[3] - anchor_x, event[4] - anchor_y, cue_index)
|
|
1392
|
+
|
|
1393
|
+
@classmethod
|
|
1394
|
+
def _trim_window_tail(cls, take):
|
|
1395
|
+
"""Drop the trailing events of the click that pressed Stop — the last
|
|
1396
|
+
press inside the Orchestrator window's rect, and everything after."""
|
|
1397
|
+
rect = cls.window_rect
|
|
1398
|
+
if rect is None:
|
|
1399
|
+
return
|
|
1400
|
+
left, top, right, bottom = rect
|
|
1401
|
+
for index in range(len(take.events) - 1, -1, -1):
|
|
1402
|
+
event = take.events[index]
|
|
1403
|
+
if event[1] == "down" and len(event) >= 5 \
|
|
1404
|
+
and left <= event[3] <= right and top <= event[4] <= bottom:
|
|
1405
|
+
del take.events[index:]
|
|
1406
|
+
return
|
|
1407
|
+
|
|
1408
|
+
@classmethod
|
|
1409
|
+
def _trim_hotkey_tail(cls, take):
|
|
1410
|
+
"""Drop the trailing Ctrl+Shift+O keystrokes the stop hotkey put on
|
|
1411
|
+
the tail (its key events were recorded before the hotkey fired)."""
|
|
1412
|
+
while take.events:
|
|
1413
|
+
event = take.events[-1]
|
|
1414
|
+
if event[1] == "key" and event[2] in _HOTKEY_KEYS:
|
|
1415
|
+
take.events.pop()
|
|
1416
|
+
elif event[1] in ("down", "up") and event[2] in _HOTKEY_FEED_IDS:
|
|
1417
|
+
take.events.pop()
|
|
1418
|
+
else:
|
|
1419
|
+
return
|
|
1420
|
+
|
|
1421
|
+
# ── replay ─────────────────────────────────────────────────────────────
|
|
1422
|
+
|
|
1423
|
+
@classmethod
|
|
1424
|
+
def play(cls, orchestration, start=0, end=None, universe=None, generalize=False):
|
|
1425
|
+
"""Replay the take — or, with start/end, just ONE command's event
|
|
1426
|
+
span (the per-row run buttons): events [start, end) inject, cues
|
|
1427
|
+
with `at` in (start, end] arm and verify, cues beyond stay silent,
|
|
1428
|
+
and restore_on_finish is SKIPPED for a partial run (restoring after
|
|
1429
|
+
one action would undo it on the spot).
|
|
1430
|
+
|
|
1431
|
+
Two modes, ONE code path for anything with a value (Lukas 09-01):
|
|
1432
|
+
`generalize=False` plays the recording — every gesture verbatim,
|
|
1433
|
+
its target's gates (collapsed parents, closed windows) opened
|
|
1434
|
+
first, overrides ignored; `generalize=True` runs every leaf edit
|
|
1435
|
+
through change_value, the override standing in for the recorded
|
|
1436
|
+
value where one is set and the RECORDED value otherwise — so an
|
|
1437
|
+
un-overridden command takes exactly the path an overridden one
|
|
1438
|
+
does, never a separate replay branch."""
|
|
1439
|
+
if cls.recording is not None or cls.replaying is not None or cls._restore_steps:
|
|
1440
|
+
return
|
|
1441
|
+
if not orchestration.events:
|
|
1442
|
+
notify("Orchestration is empty — record it first", tag="orchestrator")
|
|
1443
|
+
return
|
|
1444
|
+
start = max(0, min(start, len(orchestration.events)))
|
|
1445
|
+
# a new run clears the last outcome (red row / error bar / green row)
|
|
1446
|
+
# so what lands on screen is only THIS run's
|
|
1447
|
+
cls.last_failure = None
|
|
1448
|
+
cls.last_success = None
|
|
1449
|
+
cls.replaying = orchestration
|
|
1450
|
+
cls._replay_end = end
|
|
1451
|
+
cls._replay_partial = start > 0 or end is not None
|
|
1452
|
+
cls._remaps = cls._gesture_remaps(orchestration, generalize=generalize)
|
|
1453
|
+
cls._remap = None
|
|
1454
|
+
cls._remap_shift = None
|
|
1455
|
+
cls._glide_queue.clear()
|
|
1456
|
+
cls._inherited_anchor = cls._anchor_inheritance(orchestration)
|
|
1457
|
+
cls._remap_universe = universe # headless tests: the fake tree; live: the cache
|
|
1458
|
+
# A PARTIAL run's timeline starts at its span's first event (no
|
|
1459
|
+
# lead-in wait); a full play keeps the recorded delay before the
|
|
1460
|
+
# first event - dt 0 stays dt 0.
|
|
1461
|
+
speed = max(0.05, Toggles.Orchestrator.replay_speed)
|
|
1462
|
+
span_t0 = (orchestration.events[start][0]
|
|
1463
|
+
if 0 < start < len(orchestration.events) else 0.0)
|
|
1464
|
+
cls._replay_t0 = time.monotonic() - span_t0 / speed
|
|
1465
|
+
cls._replay_index = start
|
|
1466
|
+
# cues at indexes before the span start belong to earlier commands
|
|
1467
|
+
cls._cue_cursor = sum(1 for cue in orchestration.cues
|
|
1468
|
+
if cue_get(cue, "at", 0) <= start)
|
|
1469
|
+
cls._cue_pending = None
|
|
1470
|
+
cls._cue_wait = 0
|
|
1471
|
+
cls._cue_corrected = False
|
|
1472
|
+
cls._matched_ids = set()
|
|
1473
|
+
cls._precondition_corrected = {}
|
|
1474
|
+
cls._last_click = None
|
|
1475
|
+
cls._replay_marks = {name: stack._next_group_id
|
|
1476
|
+
for name, stack in _stacks().items()}
|
|
1477
|
+
cls._replay_marks["effects"] = EffectLedger.next_seq
|
|
1478
|
+
cls._anchor_cache = {}
|
|
1479
|
+
cls._cursor_settled = False
|
|
1480
|
+
cls._last_move_pump = -10
|
|
1481
|
+
handler = Melty.event_handler
|
|
1482
|
+
cls._virtual_x, cls._virtual_y = handler.cursor()
|
|
1483
|
+
cls._virtual_buttons = {}
|
|
1484
|
+
cls._virtual_mods = 0
|
|
1485
|
+
cls._virtual_wheel = 0.0
|
|
1486
|
+
cls._virtual_chars = []
|
|
1487
|
+
cls.status = f"replaying 0/{len(orchestration.events)}"
|
|
1488
|
+
request_render()
|
|
1489
|
+
|
|
1490
|
+
@classmethod
|
|
1491
|
+
def pump(cls):
|
|
1492
|
+
"""Per frame from SplitOverlayRenderer.process_inputs — BEFORE
|
|
1493
|
+
imgui.new_frame and stamp_io, where real input enters, so an injected
|
|
1494
|
+
event reaches the handler and imgui's io in the same frame (a real
|
|
1495
|
+
press's ordering; see the note there): inject every event whose
|
|
1496
|
+
recorded time has elapsed,
|
|
1497
|
+
pausing at cues; step a pending restore one undo group per frame
|
|
1498
|
+
(undo writes apply on the NEXT frame's render, so batching them in
|
|
1499
|
+
one frame would overwrite each other)."""
|
|
1500
|
+
cls._pump_count += 1
|
|
1501
|
+
if cls._glide_queue:
|
|
1502
|
+
cls._drain_glide()
|
|
1503
|
+
if cls.replaying is not None or cls._task is not None:
|
|
1504
|
+
cls._trace_snapshot()
|
|
1505
|
+
if cls._real_button_held():
|
|
1506
|
+
# A physical button is still down (the play click, most
|
|
1507
|
+
# often): its drag target - the window under the pointer -
|
|
1508
|
+
# is alive until it releases, and ANY injected move would
|
|
1509
|
+
# drive it (the orchestrator gets jumped by the virtual
|
|
1510
|
+
# mouse's then). Idle until the real release.
|
|
1511
|
+
request_render()
|
|
1512
|
+
return
|
|
1513
|
+
if cls._restore_steps:
|
|
1514
|
+
cls._step_restore()
|
|
1515
|
+
request_render()
|
|
1516
|
+
return
|
|
1517
|
+
if cls._task is not None:
|
|
1518
|
+
cls._assert_cursor()
|
|
1519
|
+
cls._step_task()
|
|
1520
|
+
request_render()
|
|
1521
|
+
return
|
|
1522
|
+
orchestration = cls.replaying
|
|
1523
|
+
if orchestration is None:
|
|
1524
|
+
return
|
|
1525
|
+
cls._assert_cursor()
|
|
1526
|
+
request_render()
|
|
1527
|
+
if cls._deferred_release is not None:
|
|
1528
|
+
input_id, x, y = cls._deferred_release
|
|
1529
|
+
cls._deferred_release = None
|
|
1530
|
+
cls._inject((0.0, "up", input_id, x, y))
|
|
1531
|
+
return
|
|
1532
|
+
if cls._deferred_press is not None:
|
|
1533
|
+
input_id, x, y = cls._deferred_press
|
|
1534
|
+
if cls.press_ready((x, y)):
|
|
1535
|
+
cls._deferred_press = None
|
|
1536
|
+
cls._inject((0.0, "down", input_id, x, y))
|
|
1537
|
+
# the release lands NEXT pump: imgui stamps io once a frame,
|
|
1538
|
+
# a press and release in one pump is no click
|
|
1539
|
+
cls._deferred_release = (input_id, x, y)
|
|
1540
|
+
return # the correction now owns this pump
|
|
1541
|
+
if cls._remap is not None:
|
|
1542
|
+
cls._step_remap()
|
|
1543
|
+
return
|
|
1544
|
+
if cls._cue_pending is not None and not cls._resolve_pending_cue():
|
|
1545
|
+
return
|
|
1546
|
+
speed = max(0.05, Toggles.Orchestrator.replay_speed)
|
|
1547
|
+
now = (time.monotonic() - cls._replay_t0) * speed
|
|
1548
|
+
events = orchestration.events
|
|
1549
|
+
cues = orchestration.cues
|
|
1550
|
+
end = cls._replay_end if cls._replay_end is not None else len(events)
|
|
1551
|
+
while cls._replay_index < end:
|
|
1552
|
+
if cls._replay_index in cls._remaps:
|
|
1553
|
+
# the gesture that set an overridden value: the servo plays
|
|
1554
|
+
# it (press → probe → drive to the new value → release)
|
|
1555
|
+
from meltygui.core.automation.value_core import ValueTask
|
|
1556
|
+
up_index, cue_index, value = cls._remaps.pop(cls._replay_index)
|
|
1557
|
+
cue = cues[cue_index]
|
|
1558
|
+
path = tuple(cue_get(cue, "chain") or [cue_get(cue, "name") or "?"])
|
|
1559
|
+
task = ValueTask(path, None if value is _KEEP else value,
|
|
1560
|
+
universe=cls._remap_universe)
|
|
1561
|
+
task.gates_only = value is _KEEP # open gates, where the tape presses
|
|
1562
|
+
task.gesture = cue_gesture(cue) # "header": the move archetype, `to` = (dx, dy)
|
|
1563
|
+
task.press_frac = cue_press_frac(cue)
|
|
1564
|
+
task.orchestration = orchestration
|
|
1565
|
+
task.start_frame = Melty.frame_count
|
|
1566
|
+
# the press lands where the recording pressed ON THE TARGET -
|
|
1567
|
+
# its offset from the target's live top-left, so the item
|
|
1568
|
+
# may have moved (a glide, a scroll fix) and the click
|
|
1569
|
+
# follows it; a re-pick stays on the CONTROL that was
|
|
1570
|
+
# pressed. A legacy cue with no geometry presses right
|
|
1571
|
+
# where the tape recorded it (re-anchored).
|
|
1572
|
+
task.press_offset = cue_press_offset(cue)
|
|
1573
|
+
task.control = cue_control(cue)
|
|
1574
|
+
if task.press_offset is None and task.press_frac is None:
|
|
1575
|
+
cls._injecting_index = cls._replay_index
|
|
1576
|
+
task.press_point = cls._event_xy(events[cls._replay_index], 3)
|
|
1577
|
+
cls._injecting_index = None
|
|
1578
|
+
# the recording's rhythm around the gesture: the pause before
|
|
1579
|
+
# the press (last approach move → down) and before the drag
|
|
1580
|
+
# starts (down → first move) - a drag that started the frame
|
|
1581
|
+
# the cursor arrived behaves differently from the recording
|
|
1582
|
+
down_index = cls._replay_index
|
|
1583
|
+
before_press = (events[down_index][0] - events[down_index - 1][0]
|
|
1584
|
+
if down_index > 0 else 0.0)
|
|
1585
|
+
first_move = next((events[i][0] for i in range(down_index + 1, up_index + 1)
|
|
1586
|
+
if events[i][1] == "move"), events[down_index][0])
|
|
1587
|
+
task.pacing = {"before_press": max(0.0, before_press) / speed,
|
|
1588
|
+
"before_drag": max(0.0, first_move - events[down_index][0]) / speed,
|
|
1589
|
+
# the gesture's own duration: the servo paces its travel on it
|
|
1590
|
+
"drag_duration": max(0.0, events[up_index][0] - first_move) / speed}
|
|
1591
|
+
cls._remap = (task, task.run(), up_index, cue_index)
|
|
1592
|
+
cls.status = (f"replaying {cls._replay_index}/{len(events)} · "
|
|
1593
|
+
+ ("checking gates" if task.gates_only else str(task)))
|
|
1594
|
+
return
|
|
1595
|
+
if cls._cue_cursor < len(cues) \
|
|
1596
|
+
and cue_get(cues[cls._cue_cursor], "at", 0) <= cls._replay_index:
|
|
1597
|
+
cls._cue_pending = cues[cls._cue_cursor]
|
|
1598
|
+
cls._cue_cursor += 1
|
|
1599
|
+
cls._cue_wait = 0
|
|
1600
|
+
cls._cue_corrected = False
|
|
1601
|
+
if not cls._resolve_pending_cue():
|
|
1602
|
+
return
|
|
1603
|
+
continue
|
|
1604
|
+
event = events[cls._replay_index]
|
|
1605
|
+
if event[0] > now:
|
|
1606
|
+
break
|
|
1607
|
+
shift = cls._remap_shift
|
|
1608
|
+
if shift is not None and shift[1] <= cls._replay_index <= shift[2]:
|
|
1609
|
+
# a retargeted gesture: this TAPE event moves with the
|
|
1610
|
+
# target (synthesized approach moves never do - they are
|
|
1611
|
+
# already aimed at the live point)
|
|
1612
|
+
event = cls._shift_event(event, shift[0])
|
|
1613
|
+
cls._injecting_index = cls._replay_index
|
|
1614
|
+
if event[1] == "down" and not cls.press_ready(cls._event_xy(event, 3)):
|
|
1615
|
+
# settle down (a glide to the button, hover registered); the
|
|
1616
|
+
# tape's clock PARKS at this press meanwhile - a glide that
|
|
1617
|
+
# ran on the clock made every event of the gesture due at
|
|
1618
|
+
# once when the press finally fired: down + up in one pump
|
|
1619
|
+
# (one io stamp: imgui never saw the click, the popover
|
|
1620
|
+
# never opened) and the tape typed on top of it (09-01)
|
|
1621
|
+
cls._replay_t0 = time.monotonic() - event[0] / speed
|
|
1622
|
+
cls._injecting_index = None
|
|
1623
|
+
break # resumes next pump
|
|
1624
|
+
cls._inject(event, glide=False)
|
|
1625
|
+
cls._injecting_index = None
|
|
1626
|
+
cls._replay_index += 1
|
|
1627
|
+
if cls._remap_shift is not None and cls._replay_index > cls._remap_shift[2]:
|
|
1628
|
+
cls._remap_shift = None # the retargeted gesture has played
|
|
1629
|
+
cls.status = f"replaying {cls._replay_index}/{len(events)}"
|
|
1630
|
+
if event[1] in ("down", "up") and event[2] in _IMGUI_BUTTON:
|
|
1631
|
+
# one button level per frame: imgui reads the level once a
|
|
1632
|
+
# frame, so a press and its release in the same pump is no
|
|
1633
|
+
# click at all - the rest of the tape waits a frame
|
|
1634
|
+
break
|
|
1635
|
+
if cls._replay_index >= end:
|
|
1636
|
+
# Trailing cues (the final click's undo change lands AFTER the
|
|
1637
|
+
# last event) still gate the finish - arm and resolve them here.
|
|
1638
|
+
# A bounded run arms only cues within the span (at <= end).
|
|
1639
|
+
while cls._cue_pending is None and cls._cue_cursor < len(cues) \
|
|
1640
|
+
and cue_get(cues[cls._cue_cursor], "at", 0) <= end:
|
|
1641
|
+
cls._cue_pending = cues[cls._cue_cursor]
|
|
1642
|
+
cls._cue_cursor += 1
|
|
1643
|
+
cls._cue_wait = 0
|
|
1644
|
+
cls._cue_corrected = False
|
|
1645
|
+
if not cls._resolve_pending_cue():
|
|
1646
|
+
return
|
|
1647
|
+
if cls._cue_pending is None:
|
|
1648
|
+
cls._finish()
|
|
1649
|
+
|
|
1650
|
+
@classmethod
|
|
1651
|
+
def _anchor_inheritance(cls, orchestration):
|
|
1652
|
+
"""{event_index: cue_index} for every mouse event that carries no
|
|
1653
|
+
anchor: the nearest anchored event before it, else the nearest after."""
|
|
1654
|
+
events = orchestration.events
|
|
1655
|
+
cues = orchestration.cues
|
|
1656
|
+
def anchored(event):
|
|
1657
|
+
slot = 4 if event[1] == "move" else 5
|
|
1658
|
+
if len(event) > slot and 0 <= event[slot] < len(cues) \
|
|
1659
|
+
and cue_get(cues[event[slot]], "anchor") is not None:
|
|
1660
|
+
return event[slot]
|
|
1661
|
+
return None
|
|
1662
|
+
inherited = {}
|
|
1663
|
+
last = None
|
|
1664
|
+
pending = []
|
|
1665
|
+
for index, event in enumerate(events):
|
|
1666
|
+
if event[1] not in ("move", "down", "up"):
|
|
1667
|
+
continue
|
|
1668
|
+
own = anchored(event)
|
|
1669
|
+
if own is not None:
|
|
1670
|
+
last = own
|
|
1671
|
+
for waiting in pending: # pending unanchored events: the first anchor after
|
|
1672
|
+
inherited[waiting] = own
|
|
1673
|
+
pending = []
|
|
1674
|
+
elif last is not None:
|
|
1675
|
+
inherited[index] = last
|
|
1676
|
+
else:
|
|
1677
|
+
pending.append(index)
|
|
1678
|
+
return inherited
|
|
1679
|
+
|
|
1680
|
+
@classmethod
|
|
1681
|
+
def _gesture_remaps(cls, orchestration, generalize=False):
|
|
1682
|
+
"""For every cue WITH A TARGET (an edit, an effect — a button, a
|
|
1683
|
+
raise, a scroll — whatever its stack), the gesture that produced
|
|
1684
|
+
it, with the value that gesture must set — see `play` for the two
|
|
1685
|
+
modes. Its target's preconditions are checked and fixed BEFORE the
|
|
1686
|
+
press ever lands; the tape never clicks into a covered / clipped /
|
|
1687
|
+
collapsed target and reads the miss. Keyed
|
|
1688
|
+
by the press's event index; the value is (last event of the
|
|
1689
|
+
gesture, cue index, value). A DRAG's Change coalesces while the
|
|
1690
|
+
button is held, so its press is still in flight at the cue; a TEXT
|
|
1691
|
+
edit's focus click is complete before the first keystroke, so its
|
|
1692
|
+
gesture is the nearest completed click before the cue plus the
|
|
1693
|
+
whole keystroke run around it (the servo types the final value
|
|
1694
|
+
itself, and the tape must not type again on top of it, 09-01)."""
|
|
1695
|
+
overrides = getattr(orchestration, "overrides", None) or {}
|
|
1696
|
+
events = orchestration.events
|
|
1697
|
+
remaps = {}
|
|
1698
|
+
for cue_index, cue in enumerate(orchestration.cues):
|
|
1699
|
+
if cue_get(cue, "kind") == "WindowMoveChange":
|
|
1700
|
+
# a window move is recorded at gesture END (the release is
|
|
1701
|
+
# before the cue): the completed header drag before the cue
|
|
1702
|
+
# is the gesture, the servo re-drags by the recorded delta
|
|
1703
|
+
delta = move_delta(cue)
|
|
1704
|
+
span = cls._completed_gesture(events, min(cue_get(cue, "at", 0), len(events)))
|
|
1705
|
+
if delta is not None and span is not None:
|
|
1706
|
+
down_index, end_index = span
|
|
1707
|
+
remaps[down_index] = (end_index, cue_index, delta if generalize else _KEEP)
|
|
1708
|
+
continue
|
|
1709
|
+
at = min(cue_get(cue, "at", 0), len(events))
|
|
1710
|
+
if cue_get(cue, "kind") not in _EDIT_KINDS or cue_get(cue, "editor") not in _COMMAND_VERBS:
|
|
1711
|
+
# no archetype drives this cue's value (a colour chip, an
|
|
1712
|
+
# imgui widget without a servo, an EFFECT cue: a button, a
|
|
1713
|
+
# raise, a scroll): its gesture still gets its gates opened
|
|
1714
|
+
# and the press uncovered BEFORE the tape plays it verbatim
|
|
1715
|
+
# - preconditions still depend on the widget, and the press
|
|
1716
|
+
# is never made into a covered / clipped target first.
|
|
1717
|
+
# (A legacy cue without a chain has no target to resolve:
|
|
1718
|
+
# verbatim tape, as before.)
|
|
1719
|
+
if not cue_has_target(cue):
|
|
1720
|
+
continue
|
|
1721
|
+
span = cls._cue_gesture(events, cue, at)
|
|
1722
|
+
if span is not None:
|
|
1723
|
+
down_index, end_index = span
|
|
1724
|
+
remaps[down_index] = (end_index, cue_index, _KEEP)
|
|
1725
|
+
continue
|
|
1726
|
+
if _COMMAND_VERBS.get(cue_get(cue, "editor")) == "type":
|
|
1727
|
+
span = cls._cue_gesture(events, cue, at)
|
|
1728
|
+
if span is not None:
|
|
1729
|
+
down_index, end_index = span
|
|
1730
|
+
if generalize:
|
|
1731
|
+
value = overrides.get(str(cue_index), cue_get(cue, "new"))
|
|
1732
|
+
else:
|
|
1733
|
+
value = _KEEP
|
|
1734
|
+
remaps[down_index] = (end_index, cue_index, value)
|
|
1735
|
+
continue
|
|
1736
|
+
# generalized: the override, else the value the recording set -
|
|
1737
|
+
# one path either way. Recording: KEEP, the recorded drag plays
|
|
1738
|
+
# verbatim, but the target's preconditions (enabled focus,
|
|
1739
|
+
# closed popover) are still covered and opened before its playback;
|
|
1740
|
+
# a take's gates are a property of the take
|
|
1741
|
+
if generalize:
|
|
1742
|
+
value = overrides.get(str(cue_index), cue_get(cue, "new"))
|
|
1743
|
+
else:
|
|
1744
|
+
value = _KEEP
|
|
1745
|
+
at = min(cue_get(cue, "at", 0), len(events))
|
|
1746
|
+
down_index = None
|
|
1747
|
+
for index in range(at - 1, -1, -1):
|
|
1748
|
+
event = events[index]
|
|
1749
|
+
if event[1] == "up" and event[2] in _IMGUI_BUTTON:
|
|
1750
|
+
break # a completed gesture: no press pending
|
|
1751
|
+
if event[1] == "down" and event[2] in _IMGUI_BUTTON:
|
|
1752
|
+
down_index = index
|
|
1753
|
+
break
|
|
1754
|
+
if down_index is None:
|
|
1755
|
+
continue
|
|
1756
|
+
button = events[down_index][2]
|
|
1757
|
+
up_index = next((index for index in range(down_index + 1, len(events))
|
|
1758
|
+
if events[index][1] == "up" and events[index][2] == button),
|
|
1759
|
+
len(events) - 1)
|
|
1760
|
+
remaps[down_index] = (up_index, cue_index, value)
|
|
1761
|
+
return remaps
|
|
1762
|
+
|
|
1763
|
+
@staticmethod
|
|
1764
|
+
def _keyboard_event(event):
|
|
1765
|
+
"""A key / char event, or the press/release of a keyboard key (the
|
|
1766
|
+
handler feeds those as down/up with a "key_N" id)."""
|
|
1767
|
+
return event[1] in ("key", "char") or (
|
|
1768
|
+
event[1] in ("down", "up") and event[2] not in _IMGUI_BUTTON)
|
|
1769
|
+
|
|
1770
|
+
@staticmethod
|
|
1771
|
+
def _shift_event(event, delta):
|
|
1772
|
+
"""The event with its position offset by `delta` (a relativized
|
|
1773
|
+
event keeps its anchor index — the shift is in screen space and
|
|
1774
|
+
adds after the anchor resolves)."""
|
|
1775
|
+
dx, dy = delta
|
|
1776
|
+
if event[1] == "move":
|
|
1777
|
+
return (event[0], "move", event[2] + dx, event[3] + dy) + tuple(event[4:])
|
|
1778
|
+
if event[1] in ("down", "up"):
|
|
1779
|
+
return (event[0], event[1], event[2], event[3] + dx, event[4] + dy) + tuple(event[5:])
|
|
1780
|
+
return event
|
|
1781
|
+
|
|
1782
|
+
@classmethod
|
|
1783
|
+
def _cue_gesture(cls, events, cue, at):
|
|
1784
|
+
"""(press index, last event index) of an edit cue's whole gesture:
|
|
1785
|
+
from the press the recording named (`press_index` — the first press
|
|
1786
|
+
on the target, a chip click that opened a popover included) to the
|
|
1787
|
+
end of the interaction — the release of whatever is still held at
|
|
1788
|
+
the cue and every keystroke around it, stopping at the next fresh
|
|
1789
|
+
mouse press after the cue. Cues without a press_index (legacy)
|
|
1790
|
+
fall back to _pressed_gesture."""
|
|
1791
|
+
press_index = cue_get(cue, "press_index")
|
|
1792
|
+
if not (isinstance(press_index, int) and 0 <= press_index < len(events)
|
|
1793
|
+
and events[press_index][1] == "down"
|
|
1794
|
+
and events[press_index][2] in _IMGUI_BUTTON):
|
|
1795
|
+
return cls._pressed_gesture(events, at)
|
|
1796
|
+
return press_index, cls._gesture_end(events, at, press_index)
|
|
1797
|
+
|
|
1798
|
+
@classmethod
|
|
1799
|
+
def _gesture_end(cls, events, at, down_index):
|
|
1800
|
+
"""The last event of the gesture that starts at `down_index` and
|
|
1801
|
+
produced the cue at `at`: at least the release of that press; past
|
|
1802
|
+
the cue, keystrokes / moves / the release of a press still held
|
|
1803
|
+
continue it, a NEW mouse press after the cue ends it."""
|
|
1804
|
+
end = max(down_index, at - 1)
|
|
1805
|
+
held = set()
|
|
1806
|
+
for index in range(down_index, len(events)):
|
|
1807
|
+
event = events[index]
|
|
1808
|
+
if event[1] == "down" and event[2] in _IMGUI_BUTTON:
|
|
1809
|
+
if index > at - 1 and not held:
|
|
1810
|
+
break # a fresh gesture after the cue
|
|
1811
|
+
held.add(event[2])
|
|
1812
|
+
elif event[1] == "up" and event[2] in _IMGUI_BUTTON:
|
|
1813
|
+
held.discard(event[2])
|
|
1814
|
+
end = max(end, index)
|
|
1815
|
+
elif cls._keyboard_event(event) and index >= at - 1:
|
|
1816
|
+
end = max(end, index)
|
|
1817
|
+
elif event[1] == "move" and index < at:
|
|
1818
|
+
end = max(end, index) if held else end
|
|
1819
|
+
return end
|
|
1820
|
+
|
|
1821
|
+
@classmethod
|
|
1822
|
+
def _pressed_gesture(cls, events, at):
|
|
1823
|
+
"""The gesture behind a cue of unknown shape: a press still held at
|
|
1824
|
+
the cue (a drag-style edit) → (press, its release); else the
|
|
1825
|
+
completed gesture before it (_completed_gesture). None without a
|
|
1826
|
+
press."""
|
|
1827
|
+
for index in range(at - 1, -1, -1):
|
|
1828
|
+
event = events[index]
|
|
1829
|
+
if event[1] == "up" and event[2] in _IMGUI_BUTTON:
|
|
1830
|
+
break # completed before the cue
|
|
1831
|
+
if event[1] == "down" and event[2] in _IMGUI_BUTTON:
|
|
1832
|
+
button = event[2]
|
|
1833
|
+
up_index = next((i for i in range(index + 1, len(events))
|
|
1834
|
+
if events[i][1] == "up" and events[i][2] == button),
|
|
1835
|
+
len(events) - 1)
|
|
1836
|
+
return index, up_index
|
|
1837
|
+
return cls._completed_gesture(events, at)
|
|
1838
|
+
|
|
1839
|
+
@classmethod
|
|
1840
|
+
def _completed_gesture(cls, events, at):
|
|
1841
|
+
"""(press index, last event index) of the COMPLETED mouse gesture a
|
|
1842
|
+
cue at `at` belongs to — a text edit's focus click, a window move's
|
|
1843
|
+
header drag — back over keystrokes / moves before the cue to the
|
|
1844
|
+
release and on to its press (moves between them are the click's
|
|
1845
|
+
jitter or the drag itself), forward over the keystrokes that follow
|
|
1846
|
+
the cue. None when no click precedes the cue."""
|
|
1847
|
+
release_index = None
|
|
1848
|
+
down_index = None
|
|
1849
|
+
for index in range(at - 1, -1, -1):
|
|
1850
|
+
event = events[index]
|
|
1851
|
+
if cls._keyboard_event(event) or event[1] in ("move", "change"):
|
|
1852
|
+
continue
|
|
1853
|
+
if event[1] == "up" and event[2] in _IMGUI_BUTTON and release_index is None:
|
|
1854
|
+
release_index = index
|
|
1855
|
+
continue
|
|
1856
|
+
if event[1] == "down" and event[2] in _IMGUI_BUTTON:
|
|
1857
|
+
down_index = index
|
|
1858
|
+
break
|
|
1859
|
+
if down_index is None:
|
|
1860
|
+
return None
|
|
1861
|
+
end_index = release_index if release_index is not None else down_index
|
|
1862
|
+
index = down_index + 1
|
|
1863
|
+
while index < len(events) and (cls._keyboard_event(events[index])
|
|
1864
|
+
or events[index][1] in ("move", "up")):
|
|
1865
|
+
if cls._keyboard_event(events[index]):
|
|
1866
|
+
end_index = max(end_index, index)
|
|
1867
|
+
index += 1
|
|
1868
|
+
return down_index, max(end_index, at - 1)
|
|
1869
|
+
|
|
1870
|
+
@classmethod
|
|
1871
|
+
def _step_remap(cls):
|
|
1872
|
+
"""Step the running value remap; on completion resume the tape after
|
|
1873
|
+
the gesture's release (its cue is the remap's own verification)."""
|
|
1874
|
+
task, generator, up_index, cue_index = cls._remap
|
|
1875
|
+
try:
|
|
1876
|
+
next(generator)
|
|
1877
|
+
return
|
|
1878
|
+
except StopIteration:
|
|
1879
|
+
pass
|
|
1880
|
+
except Exception as error:
|
|
1881
|
+
task.error = f"{type(error).__name__}: {error}"
|
|
1882
|
+
cls._remap = None
|
|
1883
|
+
if task.error:
|
|
1884
|
+
cls._cue_pending = cls.replaying.cues[cue_index] # the failed command, for the report
|
|
1885
|
+
cls._cue_cursor = cue_index + 1
|
|
1886
|
+
cls.abort(f"command {cue_index + 1}: {task.error}")
|
|
1887
|
+
return
|
|
1888
|
+
events = cls.replaying.events
|
|
1889
|
+
speed = max(0.05, Toggles.Orchestrator.replay_speed)
|
|
1890
|
+
if getattr(task, "gates_only", False):
|
|
1891
|
+
# gates opened (or failed to open): the tape presses now, its
|
|
1892
|
+
# own timing re-based to the press so no time is owed - and
|
|
1893
|
+
# WHERE the press is now: the gesture's value shift is the
|
|
1894
|
+
# satisfied press_point's offset from the recorded press
|
|
1895
|
+
cls._replay_t0 = time.monotonic() - events[cls._replay_index][0] / speed
|
|
1896
|
+
down_index = cls._replay_index
|
|
1897
|
+
if task.press_point is not None and events[down_index][1] == "down":
|
|
1898
|
+
cls._injecting_index = down_index
|
|
1899
|
+
recorded_x, recorded_y = cls._event_xy(events[down_index], 3)
|
|
1900
|
+
cls._injecting_index = None
|
|
1901
|
+
shift = (task.press_point[0] - recorded_x, task.press_point[1] - recorded_y)
|
|
1902
|
+
if abs(shift[0]) > 0.5 or abs(shift[1]) > 0.5:
|
|
1903
|
+
cls._remap_shift = (shift, down_index, up_index)
|
|
1904
|
+
return
|
|
1905
|
+
cls._replay_index = up_index + 1
|
|
1906
|
+
while (cls._cue_cursor < len(cls.replaying.cues)
|
|
1907
|
+
and cue_get(cls.replaying.cues[cls._cue_cursor], "at", 0) <= up_index):
|
|
1908
|
+
cls._cue_cursor += 1 # the gesture's cues: verified by the servo
|
|
1909
|
+
# re-base the clock to the RELEASE's recorded time, so the pause
|
|
1910
|
+
# between the release and whatever follows is kept as recorded
|
|
1911
|
+
cls._replay_t0 = time.monotonic() - events[up_index][0] / speed
|
|
1912
|
+
|
|
1913
|
+
@classmethod
|
|
1914
|
+
def _resolve_pending_cue(cls):
|
|
1915
|
+
"""True when replay may continue past the armed cue. Otherwise waits
|
|
1916
|
+
cue_wait_frames, tries ONE correction (re-aim at the live target),
|
|
1917
|
+
waits again, then aborts."""
|
|
1918
|
+
cue = cls._cue_pending
|
|
1919
|
+
if cls._cue_satisfied(cue):
|
|
1920
|
+
cls._cue_pending = None
|
|
1921
|
+
cls._cue_corrected = False
|
|
1922
|
+
return True
|
|
1923
|
+
cls._cue_wait += 1
|
|
1924
|
+
if cls._cue_wait <= Toggles.Orchestrator.cue_wait_frames:
|
|
1925
|
+
return False
|
|
1926
|
+
cue_index = cls._cue_cursor - 1
|
|
1927
|
+
# First correction, for ANY cue with a target - whatever its kind or
|
|
1928
|
+
# stack: the window's preconditions for the target (the same
|
|
1929
|
+
# list it shows beside the command) are satisfied at the RECORDED
|
|
1930
|
+
# press point and the gesture replays. The effect the cue names is
|
|
1931
|
+
# the verification; how the click failed is not the engine's
|
|
1932
|
+
# business - the solver's is.
|
|
1933
|
+
if (cue_index not in cls._precondition_corrected and cue_has_target(cue)
|
|
1934
|
+
and cls._correct_via_preconditions(cue, cue_index)):
|
|
1935
|
+
return False
|
|
1936
|
+
if not cls._cue_corrected:
|
|
1937
|
+
if cue_get(cue, "stack") == "effects":
|
|
1938
|
+
# No re-aim for an effect cue: the click identifies itself by
|
|
1939
|
+
# its effect, so there is no tile to aim at - report the
|
|
1940
|
+
# missing effect (and what the solver tried, if it ran).
|
|
1941
|
+
cls.abort(f"effect missing: {cue_get(cue, 'kind')} "
|
|
1942
|
+
f"'{cue_get(cue, 'name')}' after event {cue_get(cue, 'at')}"
|
|
1943
|
+
+ cls._precondition_note(cue_index))
|
|
1944
|
+
return False
|
|
1945
|
+
cls._cue_corrected = True
|
|
1946
|
+
cls._cue_wait = 0
|
|
1947
|
+
cls.status = f"correcting: {cue_get(cue, 'kind')} {cue_get(cue, 'name')}"
|
|
1948
|
+
cls._correct(cue)
|
|
1949
|
+
return False
|
|
1950
|
+
cls.abort(f"cue failed: expected {cue_get(cue, 'kind')} on "
|
|
1951
|
+
f"'{cue_get(cue, 'name')}' after event {cue_get(cue, 'at')}"
|
|
1952
|
+
+ cls._precondition_note(cue_index))
|
|
1953
|
+
return False
|
|
1954
|
+
|
|
1955
|
+
@classmethod
|
|
1956
|
+
def _precondition_note(cls, cue_index):
|
|
1957
|
+
attempts = cls._precondition_corrected.get(cue_index)
|
|
1958
|
+
if not attempts:
|
|
1959
|
+
return ""
|
|
1960
|
+
return " — preconditions tried: " + "; ".join(str(a) for a in attempts)
|
|
1961
|
+
|
|
1962
|
+
@classmethod
|
|
1963
|
+
def _correct_via_preconditions(cls, cue, cue_index):
|
|
1964
|
+
"""The window's preconditions as the correction: a gates-only
|
|
1965
|
+
ValueTask on the cue's target (path / gesture / press fraction
|
|
1966
|
+
exactly as `refresh_preconditions` lists them, the press point the
|
|
1967
|
+
RECORDED press re-anchored — where the tape is about to click),
|
|
1968
|
+
then the tape rewound to the gesture's press so it replays and the
|
|
1969
|
+
cue re-arms behind it. The task's own abort (no fix applied) lands
|
|
1970
|
+
as the cue's failure. False when the cue's gesture cannot be found
|
|
1971
|
+
(no press to replay)."""
|
|
1972
|
+
from meltygui.core.automation.value_core import ValueTask
|
|
1973
|
+
orchestration = cls.replaying
|
|
1974
|
+
events = orchestration.events
|
|
1975
|
+
at = min(cue_get(cue, "at", 0), len(events))
|
|
1976
|
+
span = cls._cue_gesture(events, cue, at)
|
|
1977
|
+
if span is None:
|
|
1978
|
+
return False
|
|
1979
|
+
down_index, up_index = span
|
|
1980
|
+
if events[down_index][1] != "down":
|
|
1981
|
+
return False
|
|
1982
|
+
path = tuple(cue_get(cue, "chain") or [cue_get(cue, "name") or "?"])
|
|
1983
|
+
task = ValueTask(path, None, universe=cls._remap_universe)
|
|
1984
|
+
task.gates_only = True
|
|
1985
|
+
task.gesture = cue_gesture(cue)
|
|
1986
|
+
task.press_frac = cue_press_frac(cue)
|
|
1987
|
+
task.press_offset = cue_press_offset(cue)
|
|
1988
|
+
task.control = cue_control(cue)
|
|
1989
|
+
if task.press_offset is None and task.press_frac is None:
|
|
1990
|
+
cls._injecting_index = down_index
|
|
1991
|
+
task.press_point = cls._event_xy(events[down_index], 3)
|
|
1992
|
+
cls._injecting_index = None
|
|
1993
|
+
task.orchestration = orchestration
|
|
1994
|
+
task.start_frame = Melty.frame_count
|
|
1995
|
+
cls._precondition_corrected[cue_index] = task.attempts
|
|
1996
|
+
# rewind: the gesture replays once the gates are open, the cue
|
|
1997
|
+
# arms again after it (a second miss aborts)
|
|
1998
|
+
cls._cue_pending = None
|
|
1999
|
+
cls._cue_wait = 0
|
|
2000
|
+
cls._cue_corrected = False
|
|
2001
|
+
cls._cue_cursor = cue_index
|
|
2002
|
+
cls._replay_index = down_index
|
|
2003
|
+
cls._remap_shift = None
|
|
2004
|
+
cls._remap = (task, task.run(), up_index, cue_index)
|
|
2005
|
+
cls.status = (f"correcting: {cue_get(cue, 'kind')} {cue_get(cue, 'name')} · "
|
|
2006
|
+
f"preconditions")
|
|
2007
|
+
request_render()
|
|
2008
|
+
return True
|
|
2009
|
+
|
|
2010
|
+
@classmethod
|
|
2011
|
+
def _cue_satisfied(cls, cue):
|
|
2012
|
+
"""A live change matching the cue's anchor appeared since replay
|
|
2013
|
+
start (and wasn't already claimed by an earlier identical cue)."""
|
|
2014
|
+
stack_name, kind = cue_get(cue, "stack"), cue_get(cue, "kind")
|
|
2015
|
+
display_name, new_repr = cue_get(cue, "name"), cue_get(cue, "new_repr")
|
|
2016
|
+
if stack_name == "effects":
|
|
2017
|
+
mark = cls._replay_marks.get("effects", 0)
|
|
2018
|
+
for entry in reversed(EffectLedger.entries):
|
|
2019
|
+
if entry.seq <= mark:
|
|
2020
|
+
break
|
|
2021
|
+
if id(entry) in cls._matched_ids:
|
|
2022
|
+
continue
|
|
2023
|
+
if entry.kind == kind and entry.name == display_name:
|
|
2024
|
+
cls._matched_ids.add(id(entry))
|
|
2025
|
+
return True
|
|
2026
|
+
# State evidence: a raise cue means "this window is at front" -
|
|
2027
|
+
# the publisher only fires on an ACTUAL restack (noise gating),
|
|
2028
|
+
# so a replayed click on an already-front window produces no
|
|
2029
|
+
# ledger entry. The state satisfies the cue just as truthfully.
|
|
2030
|
+
if kind == "raise" and _front_window_name() == display_name:
|
|
2031
|
+
return True
|
|
2032
|
+
return False
|
|
2033
|
+
stack = _stacks().get(stack_name)
|
|
2034
|
+
if stack is None:
|
|
2035
|
+
return True
|
|
2036
|
+
mark = cls._replay_marks.get(stack_name, 0)
|
|
2037
|
+
for change in reversed(stack.history):
|
|
2038
|
+
if change.group_id <= mark:
|
|
2039
|
+
break
|
|
2040
|
+
if id(change) in cls._matched_ids:
|
|
2041
|
+
continue
|
|
2042
|
+
if type(change).__name__ != kind or str(change.display_name) != display_name:
|
|
2043
|
+
continue
|
|
2044
|
+
if Toggles.Orchestrator.cue_match_values and _short_repr(change.new) != new_repr:
|
|
2045
|
+
continue
|
|
2046
|
+
cls._matched_ids.add(id(change))
|
|
2047
|
+
return True
|
|
2048
|
+
return False
|
|
2049
|
+
|
|
2050
|
+
@classmethod
|
|
2051
|
+
def _correct(cls, cue):
|
|
2052
|
+
"""Re-aim: the recorded click missed because the target moved since
|
|
2053
|
+
recording. Resolve the cue's draw_state LIVE (tile repr against the
|
|
2054
|
+
cache, the CaretLocation trick) and replay the last press at its
|
|
2055
|
+
current center; no live target -> re-press at the recorded spot."""
|
|
2056
|
+
input_id, x, y = cls._last_click or ("left_mouse", cls._virtual_x, cls._virtual_y)
|
|
2057
|
+
tile_repr = cue_get(cue, "tile")
|
|
2058
|
+
cache = getattr(Melty, "cache", None)
|
|
2059
|
+
if cache is not None and tile_repr and tile_repr != "None":
|
|
2060
|
+
for tile_id, draw_state in cache.key_to_draw_state.items():
|
|
2061
|
+
if repr(tile_id)[:200] == tile_repr and draw_state is not None:
|
|
2062
|
+
live_left = getattr(draw_state, "abs_left", None)
|
|
2063
|
+
live_top = getattr(draw_state, "abs_top", None)
|
|
2064
|
+
if live_left is not None and live_top is not None:
|
|
2065
|
+
x = live_left + (getattr(draw_state, "width", 0) or 0) / 2.0
|
|
2066
|
+
y = live_top + (getattr(draw_state, "height", 0) or 0) / 2.0
|
|
2067
|
+
break
|
|
2068
|
+
cls._inject((0.0, "move", x, y))
|
|
2069
|
+
cls._deferred_press = (input_id, x, y) # pressed once the cursor settles
|
|
2070
|
+
|
|
2071
|
+
@classmethod
|
|
2072
|
+
def _anchor_origin(cls, cue_index):
|
|
2073
|
+
"""Live (left, top) of the window a relativized event references —
|
|
2074
|
+
resolved ONCE per cue per replay (a window that moves mid-replay must
|
|
2075
|
+
not shift the events recorded against where it stood). Resolution:
|
|
2076
|
+
the cue's window tile against the live cache (nested windows too),
|
|
2077
|
+
then the registered window by name; both missing → the RECORDED
|
|
2078
|
+
origin, which reproduces the original absolute coordinates."""
|
|
2079
|
+
cached = cls._anchor_cache.get(cue_index)
|
|
2080
|
+
if cached is not None:
|
|
2081
|
+
return cached
|
|
2082
|
+
cues = cls.replaying.cues if cls.replaying is not None else []
|
|
2083
|
+
anchor = (cue_get(cues[cue_index], "anchor")
|
|
2084
|
+
if 0 <= cue_index < len(cues) else None)
|
|
2085
|
+
origin = cls.resolve_anchor_origin(anchor)
|
|
2086
|
+
cls._anchor_cache[cue_index] = origin
|
|
2087
|
+
return origin
|
|
2088
|
+
|
|
2089
|
+
@classmethod
|
|
2090
|
+
def resolve_anchor_origin(cls, anchor):
|
|
2091
|
+
"""Live (left, top) for a recorded anchor tuple — the cue's window
|
|
2092
|
+
tile against the live cache (nested windows too), then the
|
|
2093
|
+
registered window by name, else the RECORDED origin (reproduces the
|
|
2094
|
+
original absolute coordinates). Shared by replay (_anchor_origin)
|
|
2095
|
+
and command playback (re-anchoring a take's approach path)."""
|
|
2096
|
+
origin = (0.0, 0.0)
|
|
2097
|
+
if anchor is not None:
|
|
2098
|
+
tile_repr, window_name, recorded_left, recorded_top = anchor
|
|
2099
|
+
origin = (recorded_left, recorded_top)
|
|
2100
|
+
window_ds = None
|
|
2101
|
+
cache = getattr(Melty, "cache", None)
|
|
2102
|
+
if cache is not None and tile_repr and tile_repr != "None":
|
|
2103
|
+
for tile_id, draw_state in cache.key_to_draw_state.items():
|
|
2104
|
+
if repr(tile_id)[:200] == tile_repr and draw_state is not None:
|
|
2105
|
+
window_ds = draw_state
|
|
2106
|
+
break
|
|
2107
|
+
if window_ds is None:
|
|
2108
|
+
# registered_windows is keyed by TILE ID, not name - scan
|
|
2109
|
+
# values for display name (a .get(name) here was a dead
|
|
2110
|
+
# fallback, triggered by a name-keyed test fake)
|
|
2111
|
+
for managed in (getattr(Melty, "registered_windows", None) or {}).values():
|
|
2112
|
+
if str(getattr(managed, "name", "")).split("##")[0] == window_name:
|
|
2113
|
+
window_ds = getattr(managed, "draw_state", None)
|
|
2114
|
+
break
|
|
2115
|
+
if window_ds is not None:
|
|
2116
|
+
live_left = getattr(window_ds, "abs_left", None)
|
|
2117
|
+
live_top = getattr(window_ds, "abs_top", None)
|
|
2118
|
+
if live_left is not None and live_top is not None:
|
|
2119
|
+
origin = (live_left, live_top)
|
|
2120
|
+
return origin
|
|
2121
|
+
|
|
2122
|
+
@classmethod
|
|
2123
|
+
def _event_xy(cls, event, x_slot):
|
|
2124
|
+
"""An event's cursor position in SCREEN coordinates: relativized
|
|
2125
|
+
events (trailing cue-index element) shift by their anchor's live
|
|
2126
|
+
origin, absolute ones pass through."""
|
|
2127
|
+
x, y = event[x_slot], event[x_slot + 1]
|
|
2128
|
+
if len(event) > x_slot + 2:
|
|
2129
|
+
left, top = cls._anchor_origin(event[x_slot + 2])
|
|
2130
|
+
return x + left, y + top
|
|
2131
|
+
inherited = cls._inherited_anchor.get(cls._injecting_index)
|
|
2132
|
+
if inherited is not None:
|
|
2133
|
+
# absolute event: shift by the inherited anchor's displacement
|
|
2134
|
+
# since recording, so the offset stays continuous
|
|
2135
|
+
anchor = cue_get(cls.replaying.cues[inherited], "anchor")
|
|
2136
|
+
left, top = cls._anchor_origin(inherited)
|
|
2137
|
+
return x + (left - anchor[2]), y + (top - anchor[3])
|
|
2138
|
+
return x, y
|
|
2139
|
+
|
|
2140
|
+
@classmethod
|
|
2141
|
+
def _trace_snapshot(cls):
|
|
2142
|
+
"""One trace row per driving pump (state as this pump BEGINS, plus
|
|
2143
|
+
what the previous pump injected)."""
|
|
2144
|
+
try:
|
|
2145
|
+
io = imgui.get_io()
|
|
2146
|
+
io_pos = tuple(round(v, 1) for v in io.mouse_pos)
|
|
2147
|
+
io_down = bool(io.mouse_down[0])
|
|
2148
|
+
except Exception:
|
|
2149
|
+
io_pos, io_down = None, None
|
|
2150
|
+
focused = hovered_attr = None
|
|
2151
|
+
try:
|
|
2152
|
+
win = Melty.glfw_window
|
|
2153
|
+
if win is not None:
|
|
2154
|
+
focused = bool(glfw.get_window_attrib(win, glfw.FOCUSED))
|
|
2155
|
+
hovered_attr = bool(glfw.get_window_attrib(win, glfw.HOVERED))
|
|
2156
|
+
except Exception:
|
|
2157
|
+
pass
|
|
2158
|
+
hovered = getattr(Melty, "hovered_ds", None)
|
|
2159
|
+
handler = Melty.event_handler
|
|
2160
|
+
try:
|
|
2161
|
+
handler_down = bool(handler.is_down("left_mouse"))
|
|
2162
|
+
except Exception:
|
|
2163
|
+
handler_down = None
|
|
2164
|
+
target = getattr(cls._task, "trace_target", None)
|
|
2165
|
+
if target is None and cls._remap is not None:
|
|
2166
|
+
target = getattr(cls._remap[0], "trace_target", None)
|
|
2167
|
+
target_state = None
|
|
2168
|
+
if target is not None:
|
|
2169
|
+
# `blit_last`: the target's tile was served from the blit cache
|
|
2170
|
+
# on the frame just rendered - its imgui item was NOT submitted,
|
|
2171
|
+
# which is what drops imgui's ActiveId (and with it imgui_active,
|
|
2172
|
+
# the gate that keeps a window's header item off a window drag)
|
|
2173
|
+
served = getattr(target, "_blit_served_frame", None)
|
|
2174
|
+
target_state = dict(
|
|
2175
|
+
bounding=bool(getattr(target, "_bounding_hovered", False)),
|
|
2176
|
+
active=bool(getattr(target, "_imgui_is_active", False)),
|
|
2177
|
+
activated=bool(getattr(target, "_imgui_is_activated", False)),
|
|
2178
|
+
edited=bool(getattr(target, "_imgui_is_edited", False)),
|
|
2179
|
+
item_hovered=bool(getattr(target, "_imgui_is_item_hovered", False)),
|
|
2180
|
+
bvh_hover=id(target) in (getattr(Melty, "bvh_hover_ids", None) or ()),
|
|
2181
|
+
in_bvh=any(d is target for d in (getattr(Melty, "_bvh_id_to_ds", None) or {}).values()),
|
|
2182
|
+
blit_last=(served is not None and served >= Melty.frame_count - 1),
|
|
2183
|
+
served_frame=served,
|
|
2184
|
+
value=getattr(target, "_raw_input_value", None),
|
|
2185
|
+
rect=(getattr(target, "abs_left", None), getattr(target, "abs_top", None),
|
|
2186
|
+
getattr(target, "width", None), getattr(target, "height", None)))
|
|
2187
|
+
# io is read BEFORE this pump's stamp_io (the pump runs first in
|
|
2188
|
+
# process_inputs, after the stock backend copied the REAL pointer in),
|
|
2189
|
+
# so real_io_* is the physical state, the virtual state is beside it.
|
|
2190
|
+
cls._trace.append(dict(
|
|
2191
|
+
pump=cls._pump_count, frame=Melty.frame_count, real_io_pos=io_pos, real_io_down=io_down,
|
|
2192
|
+
virtual=(round(cls._virtual_x, 1), round(cls._virtual_y, 1)),
|
|
2193
|
+
virtual_buttons=sorted(cls._virtual_buttons), real_down=sorted(cls._real_down),
|
|
2194
|
+
handler_down=handler_down,
|
|
2195
|
+
main_hovered=bool(getattr(Melty, "imgui_main_window_hovered", False)),
|
|
2196
|
+
imgui_active=bool(getattr(Melty, "imgui_active", False)),
|
|
2197
|
+
active_pending=bool(getattr(Melty, "imgui_active_pending", False)),
|
|
2198
|
+
on_drag=bool(getattr(Melty, "on_drag", False)),
|
|
2199
|
+
hovered_ds=str(getattr(hovered, "name", None)).split("##")[0] if hovered else None,
|
|
2200
|
+
glfw_focused=focused, glfw_hovered=hovered_attr,
|
|
2201
|
+
target=target_state, injected=list(cls._trace_injected)))
|
|
2202
|
+
cls._trace_injected = []
|
|
2203
|
+
|
|
2204
|
+
@classmethod
|
|
2205
|
+
def _real_button_held(cls):
|
|
2206
|
+
"""A physical mouse button is down. Stale entries (a release the
|
|
2207
|
+
platform ate — a compositor grab) are dropped through the backend's
|
|
2208
|
+
button probe, so a lost release can't wedge the engine."""
|
|
2209
|
+
if not cls._real_down:
|
|
2210
|
+
return False
|
|
2211
|
+
from meltygui.core.input.input_handler import _BUTTON_PROBE
|
|
2212
|
+
probe = _BUTTON_PROBE.get("fn")
|
|
2213
|
+
for button in list(cls._real_down):
|
|
2214
|
+
if probe is not None and probe(button) is False:
|
|
2215
|
+
cls._real_down.discard(button)
|
|
2216
|
+
return bool(cls._real_down)
|
|
2217
|
+
|
|
2218
|
+
@classmethod
|
|
2219
|
+
def press_ready(cls, point):
|
|
2220
|
+
"""Whether a press at `point` may be injected NOW. If the cursor has
|
|
2221
|
+
never been moved this run, inject a move to `point` (so the press
|
|
2222
|
+
lands where the take says, not under the real pointer); either way
|
|
2223
|
+
require SETTLE_PUMPS pumps since the last injected move."""
|
|
2224
|
+
if cls._real_button_held():
|
|
2225
|
+
return False
|
|
2226
|
+
if cls._glide_queue:
|
|
2227
|
+
return False # still travelling
|
|
2228
|
+
if not cls._cursor_settled:
|
|
2229
|
+
cls._inject((0.0, "move", point[0], point[1]))
|
|
2230
|
+
return False
|
|
2231
|
+
# the press point is away from the cursor (a gate click left it on a
|
|
2232
|
+
# header; the tape resumed at its press): move there with the
|
|
2233
|
+
# mouse UP before pressing - a press that jumps lands on whatever
|
|
2234
|
+
# the last render had hovered at the OLD spot, and the recorded
|
|
2235
|
+
# drag then moves away (the window by its header, 09-01)
|
|
2236
|
+
distance = ((point[0] - cls._virtual_x) ** 2 + (point[1] - cls._virtual_y) ** 2) ** 0.5
|
|
2237
|
+
if distance > cls.PRESS_JUMP_PX and not cls._virtual_buttons:
|
|
2238
|
+
cls._inject((0.0, "move", point[0], point[1])) # glide=True: queued up
|
|
2239
|
+
return False
|
|
2240
|
+
return cls._pump_count - cls._last_move_pump >= cls.SETTLE_PUMPS
|
|
2241
|
+
|
|
2242
|
+
@classmethod
|
|
2243
|
+
def _inject(cls, event, glide=True):
|
|
2244
|
+
"""Inject an event — through the continuous-mouse glide queue when a
|
|
2245
|
+
SYNTHESIZED positional event would jump the cursor (see
|
|
2246
|
+
_glide_queue). `glide=False` is the verbatim replay stream: its
|
|
2247
|
+
jumps are the recording's own. A move while a virtual button is
|
|
2248
|
+
held is a drag step (the servo paces those itself), never glided."""
|
|
2249
|
+
if Toggles.Orchestrator.continuous_mouse:
|
|
2250
|
+
if cls._glide_queue:
|
|
2251
|
+
if not glide:
|
|
2252
|
+
# an explicitly paced injection (change_value's drag moves,
|
|
2253
|
+
# presses, releases): it must never wait behind a tape
|
|
2254
|
+
# glide - flush that glide to its endpoint first so
|
|
2255
|
+
# sequence holds, then deliver
|
|
2256
|
+
while cls._glide_queue:
|
|
2257
|
+
item = cls._glide_queue.popleft()
|
|
2258
|
+
if item[0] == "glide":
|
|
2259
|
+
cls._deliver((0.0, "move", item[2][0], item[2][1]))
|
|
2260
|
+
else:
|
|
2261
|
+
cls._deliver(item)
|
|
2262
|
+
else:
|
|
2263
|
+
cls._glide_queue.append(event) # keep order behind a tape glide
|
|
2264
|
+
return
|
|
2265
|
+
kind = event[1]
|
|
2266
|
+
if (glide and kind in ("move", "down", "up") and cls._cursor_settled
|
|
2267
|
+
and not cls._virtual_buttons):
|
|
2268
|
+
slot = 2 if kind == "move" else 3
|
|
2269
|
+
x, y = cls._event_xy(event, slot)
|
|
2270
|
+
sx, sy = cls._virtual_x, cls._virtual_y
|
|
2271
|
+
distance = ((x - sx) ** 2 + (y - sy) ** 2) ** 0.5
|
|
2272
|
+
if distance > cls.PRESS_JUMP_PX:
|
|
2273
|
+
# a wall-clock glide: ("glide", from, to, t0, duration) -
|
|
2274
|
+
# _drain_glide emits the eased position each pump until
|
|
2275
|
+
# the duration elapses, then the queued event itself
|
|
2276
|
+
from meltygui.core.automation.value_core import glide_seconds
|
|
2277
|
+
cls._glide_queue.append(("glide", (sx, sy), (x, y), time.monotonic(),
|
|
2278
|
+
glide_seconds(distance)))
|
|
2279
|
+
cls._glide_queue.append(event)
|
|
2280
|
+
return
|
|
2281
|
+
cls._deliver(event)
|
|
2282
|
+
|
|
2283
|
+
@classmethod
|
|
2284
|
+
def _drain_glide(cls):
|
|
2285
|
+
"""One glide step per pump; the instantaneous events queued behind
|
|
2286
|
+
it flow out in the same pump once the glide has landed."""
|
|
2287
|
+
while cls._glide_queue:
|
|
2288
|
+
item = cls._glide_queue[0]
|
|
2289
|
+
if item[0] == "glide":
|
|
2290
|
+
_tag, (sx, sy), (x, y), t0, duration = item
|
|
2291
|
+
fraction = min(1.0, (time.monotonic() - t0) / max(1e-6, duration))
|
|
2292
|
+
eased = fraction * fraction * (3.0 - 2.0 * fraction)
|
|
2293
|
+
cls._deliver((0.0, "move", sx + (x - sx) * eased, sy + (y - sy) * eased))
|
|
2294
|
+
if fraction < 1.0:
|
|
2295
|
+
return # still travelling
|
|
2296
|
+
cls._glide_queue.popleft()
|
|
2297
|
+
continue # land: release what queued behind
|
|
2298
|
+
cls._glide_queue.popleft()
|
|
2299
|
+
cls._deliver(item)
|
|
2300
|
+
|
|
2301
|
+
@classmethod
|
|
2302
|
+
def _deliver(cls, event):
|
|
2303
|
+
_dt, kind = event[0], event[1]
|
|
2304
|
+
cls._trace_injected.append(tuple(event[1:]))
|
|
2305
|
+
handler = Melty.event_handler
|
|
2306
|
+
cls._injecting = True
|
|
2307
|
+
try:
|
|
2308
|
+
if kind == "move":
|
|
2309
|
+
x, y = cls._event_xy(event, 2)
|
|
2310
|
+
cls._virtual_x, cls._virtual_y = x, y
|
|
2311
|
+
cls._last_move_pump = cls._pump_count
|
|
2312
|
+
cls._cursor_settled = True
|
|
2313
|
+
handler.feed_move(x, y)
|
|
2314
|
+
elif kind == "down":
|
|
2315
|
+
input_id = event[2]
|
|
2316
|
+
x, y = cls._event_xy(event, 3)
|
|
2317
|
+
cls._virtual_x, cls._virtual_y = x, y
|
|
2318
|
+
if input_id in _IMGUI_BUTTON:
|
|
2319
|
+
cls._virtual_buttons[input_id] = True
|
|
2320
|
+
cls._last_click = (input_id, x, y)
|
|
2321
|
+
# click ripple - per-button tint, sequential key so
|
|
2322
|
+
# simultaneous ripples coexist
|
|
2323
|
+
ripple_tint = {"left_mouse": (1.0, 0.85, 0.3),
|
|
2324
|
+
"right_mouse": (0.45, 0.7, 1.0),
|
|
2325
|
+
"middle_mouse": (0.6, 1.0, 0.6)}[input_id]
|
|
2326
|
+
cls._click_seq += 1
|
|
2327
|
+
Melty.emphasize_click(f"orch-click-{cls._click_seq}", (x, y),
|
|
2328
|
+
tint=ripple_tint)
|
|
2329
|
+
handler.feed_down(input_id, x, y)
|
|
2330
|
+
elif kind == "up":
|
|
2331
|
+
input_id = event[2]
|
|
2332
|
+
x, y = cls._event_xy(event, 3)
|
|
2333
|
+
cls._virtual_x, cls._virtual_y = x, y
|
|
2334
|
+
cls._virtual_buttons.pop(input_id, None)
|
|
2335
|
+
handler.feed_up(input_id, x, y)
|
|
2336
|
+
elif kind == "change":
|
|
2337
|
+
input_id, value = event[2], event[3]
|
|
2338
|
+
if input_id == "scroll_y":
|
|
2339
|
+
cls._virtual_wheel += value
|
|
2340
|
+
handler.feed_change(input_id, value)
|
|
2341
|
+
elif kind == "key":
|
|
2342
|
+
key, mods = event[2], event[3]
|
|
2343
|
+
cls._virtual_mods = mods
|
|
2344
|
+
handler.set_modifiers(
|
|
2345
|
+
shift=bool(mods & glfw.MOD_SHIFT), ctrl=bool(mods & glfw.MOD_CONTROL),
|
|
2346
|
+
alt=bool(mods & glfw.MOD_ALT), meta=bool(mods & glfw.MOD_SUPER))
|
|
2347
|
+
Melty.frame_key_events.append((key, mods))
|
|
2348
|
+
elif kind == "char":
|
|
2349
|
+
cls._virtual_chars.append(event[2])
|
|
2350
|
+
finally:
|
|
2351
|
+
cls._injecting = False
|
|
2352
|
+
|
|
2353
|
+
@classmethod
|
|
2354
|
+
def stamp_io(cls, io):
|
|
2355
|
+
"""From SplitOverlayRenderer.process_inputs (frame start): while a
|
|
2356
|
+
replay OR a directed task (change_value) drives, the virtual state
|
|
2357
|
+
replaces the real pointer/keys for imgui — the real ones were muted
|
|
2358
|
+
at the handler funnel."""
|
|
2359
|
+
if cls.replaying is None and cls._task is None:
|
|
2360
|
+
return
|
|
2361
|
+
io.mouse_pos = (cls._virtual_x, cls._virtual_y)
|
|
2362
|
+
for input_id, index in _IMGUI_BUTTON.items():
|
|
2363
|
+
io.mouse_down[index] = bool(cls._virtual_buttons.get(input_id))
|
|
2364
|
+
mods = cls._virtual_mods
|
|
2365
|
+
io.key_shift = bool(mods & glfw.MOD_SHIFT)
|
|
2366
|
+
io.key_ctrl = bool(mods & glfw.MOD_CONTROL)
|
|
2367
|
+
io.key_alt = bool(mods & glfw.MOD_ALT)
|
|
2368
|
+
io.key_super = bool(mods & glfw.MOD_SUPER)
|
|
2369
|
+
if cls._virtual_wheel:
|
|
2370
|
+
io.mouse_wheel = cls._virtual_wheel
|
|
2371
|
+
cls._virtual_wheel = 0.0
|
|
2372
|
+
for codepoint in cls._virtual_chars:
|
|
2373
|
+
io.add_input_character(codepoint)
|
|
2374
|
+
cls._virtual_chars = []
|
|
2375
|
+
|
|
2376
|
+
@classmethod
|
|
2377
|
+
def _assert_cursor(cls):
|
|
2378
|
+
"""Once per driving frame: hold the virtual-pointer emphasis note on
|
|
2379
|
+
the engine's cursor (a lease — the overlay pass force-releases it if
|
|
2380
|
+
the engine stops asserting, and _release_cursor fades it on finish)."""
|
|
2381
|
+
Melty.emphasize_cursor("orch-cursor",
|
|
2382
|
+
lambda: (Orchestrator._virtual_x, Orchestrator._virtual_y))
|
|
2383
|
+
|
|
2384
|
+
@classmethod
|
|
2385
|
+
def _release_cursor(cls):
|
|
2386
|
+
note = Melty.emphasis_notes.get("orch-cursor")
|
|
2387
|
+
if note is not None:
|
|
2388
|
+
Melty.emphasize_cursor("orch-cursor", note.center, hold=False)
|
|
2389
|
+
|
|
2390
|
+
@classmethod
|
|
2391
|
+
def _release_virtual(cls):
|
|
2392
|
+
"""Feed an UP for every virtually-held button so the handler never
|
|
2393
|
+
latches a phantom drag past the replay."""
|
|
2394
|
+
handler = Melty.event_handler
|
|
2395
|
+
cls._injecting = True
|
|
2396
|
+
try:
|
|
2397
|
+
for input_id in list(cls._virtual_buttons):
|
|
2398
|
+
handler.feed_up(input_id, cls._virtual_x, cls._virtual_y)
|
|
2399
|
+
finally:
|
|
2400
|
+
cls._injecting = False
|
|
2401
|
+
cls._virtual_buttons = {}
|
|
2402
|
+
cls._virtual_wheel = 0.0
|
|
2403
|
+
cls._virtual_chars = []
|
|
2404
|
+
cls._virtual_mods = 0
|
|
2405
|
+
|
|
2406
|
+
@classmethod
|
|
2407
|
+
def _finish(cls):
|
|
2408
|
+
orchestration = cls.replaying
|
|
2409
|
+
cls._remap = None
|
|
2410
|
+
cls._remap_shift = None
|
|
2411
|
+
cls._deferred_press = None
|
|
2412
|
+
cls._deferred_release = None
|
|
2413
|
+
cls.replaying = None
|
|
2414
|
+
cls._cue_pending = None
|
|
2415
|
+
cls._glide_queue.clear()
|
|
2416
|
+
cls._release_virtual()
|
|
2417
|
+
cls._release_cursor()
|
|
2418
|
+
cls.status = ""
|
|
2419
|
+
was_partial = cls._replay_partial
|
|
2420
|
+
cls._replay_end = None
|
|
2421
|
+
cls._replay_partial = False
|
|
2422
|
+
cls._clear_failure_for(orchestration)
|
|
2423
|
+
cls._record_success(orchestration)
|
|
2424
|
+
if orchestration.restore_on_finish and not was_partial:
|
|
2425
|
+
cls._restore_steps = [(name, cls._replay_marks.get(name, 0))
|
|
2426
|
+
for name in _stacks()]
|
|
2427
|
+
cls.status = "restoring"
|
|
2428
|
+
else:
|
|
2429
|
+
notify(f"Orchestration '{orchestration.name}' finished",
|
|
2430
|
+
tint=(0.5, 0.9, 0.5, 1.0), tag="orchestrator")
|
|
2431
|
+
request_render()
|
|
2432
|
+
|
|
2433
|
+
@classmethod
|
|
2434
|
+
def abort(cls, reason):
|
|
2435
|
+
"""Stop a replay in place — no restore, the app stays as it is (a
|
|
2436
|
+
partial restore over an unverified state is scarier than an honest
|
|
2437
|
+
stop)."""
|
|
2438
|
+
if cls.replaying is None and not cls._restore_steps and cls._task is None:
|
|
2439
|
+
return
|
|
2440
|
+
if cls.replaying is not None:
|
|
2441
|
+
cls._record_failure(cls.replaying, reason,
|
|
2442
|
+
event_index=cls._replay_index,
|
|
2443
|
+
cue_index=(cls._cue_cursor - 1
|
|
2444
|
+
if cls._cue_pending is not None else None))
|
|
2445
|
+
elif cls._task is not None:
|
|
2446
|
+
cls._record_failure(getattr(cls._task, "orchestration", None), reason)
|
|
2447
|
+
if cls._task is not None:
|
|
2448
|
+
cls._task.fail(reason)
|
|
2449
|
+
cls._task = None
|
|
2450
|
+
cls._remap = None
|
|
2451
|
+
cls._remap_shift = None
|
|
2452
|
+
cls._deferred_press = None
|
|
2453
|
+
cls._deferred_release = None
|
|
2454
|
+
cls.replaying = None
|
|
2455
|
+
cls._cue_pending = None
|
|
2456
|
+
cls._restore_steps = []
|
|
2457
|
+
cls._replay_end = None
|
|
2458
|
+
cls._replay_partial = False
|
|
2459
|
+
cls._glide_queue.clear()
|
|
2460
|
+
cls._release_virtual()
|
|
2461
|
+
cls._release_cursor()
|
|
2462
|
+
cls.status = ""
|
|
2463
|
+
notify(f"Replay stopped: {reason}", tint=(0.95, 0.6, 0.3, 1.0),
|
|
2464
|
+
tag="orchestrator", urgent=True)
|
|
2465
|
+
request_render()
|
|
2466
|
+
|
|
2467
|
+
@classmethod
|
|
2468
|
+
def _record_failure(cls, orchestration, reason, event_index=None, cue_index=None):
|
|
2469
|
+
cls.last_failure = types.SimpleNamespace(
|
|
2470
|
+
orchestration=orchestration, reason=str(reason),
|
|
2471
|
+
event_index=event_index, cue_index=cue_index, when=time.time())
|
|
2472
|
+
success = cls.last_success
|
|
2473
|
+
if success is not None and success.orchestration is orchestration:
|
|
2474
|
+
cls.last_success = None
|
|
2475
|
+
# The full report also lands in a FILE (the error bar's copy is the
|
|
2476
|
+
# summary text): a 600-pump engine trace is too long for a clipboard
|
|
2477
|
+
# round-trip through a chat, and this way it can be read in place.
|
|
2478
|
+
try:
|
|
2479
|
+
import os
|
|
2480
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
2481
|
+
return # the test-suite's failures must not clobber the studio's
|
|
2482
|
+
path = os.path.expanduser("~/.lsd/orchestrator_failure.txt")
|
|
2483
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
2484
|
+
with open(path, "w") as handle:
|
|
2485
|
+
handle.write(failure_report(cls.last_failure))
|
|
2486
|
+
except Exception:
|
|
2487
|
+
pass
|
|
2488
|
+
return
|
|
2489
|
+
|
|
2490
|
+
@classmethod
|
|
2491
|
+
def _record_success(cls, orchestration):
|
|
2492
|
+
if orchestration is None:
|
|
2493
|
+
return
|
|
2494
|
+
cls.last_success = types.SimpleNamespace(orchestration=orchestration,
|
|
2495
|
+
when=time.time())
|
|
2496
|
+
|
|
2497
|
+
@classmethod
|
|
2498
|
+
def _clear_failure_for(cls, orchestration):
|
|
2499
|
+
"""A take that finishes successfully clears its own failure record."""
|
|
2500
|
+
failure = cls.last_failure
|
|
2501
|
+
if failure is not None and failure.orchestration is orchestration:
|
|
2502
|
+
cls.last_failure = None
|
|
2503
|
+
|
|
2504
|
+
@classmethod
|
|
2505
|
+
def _step_restore(cls):
|
|
2506
|
+
"""One undo group per frame: undo writes land through
|
|
2507
|
+
Melty.undo_requests on the NEXT rendered frame, so popping the whole
|
|
2508
|
+
span at once would overwrite same-target requests."""
|
|
2509
|
+
name, mark = cls._restore_steps[0]
|
|
2510
|
+
stack = _stacks()[name]
|
|
2511
|
+
if stack.history and stack.history[-1].group_id > mark:
|
|
2512
|
+
if name == "edits":
|
|
2513
|
+
UndoManager.undo()
|
|
2514
|
+
else:
|
|
2515
|
+
NavUndo.undo()
|
|
2516
|
+
return
|
|
2517
|
+
cls._restore_steps.pop(0)
|
|
2518
|
+
if not cls._restore_steps:
|
|
2519
|
+
cls.status = ""
|
|
2520
|
+
notify("State restored", tint=(0.5, 0.9, 0.5, 1.0),
|
|
2521
|
+
tag="orchestrator")
|
|
2522
|
+
|
|
2523
|
+
# ── directed tasks (change_value) ────────────────────────────────────
|
|
2524
|
+
|
|
2525
|
+
@classmethod
|
|
2526
|
+
def submit(cls, task):
|
|
2527
|
+
"""Run a directed task (change_value.ValueTask): its generator is
|
|
2528
|
+
stepped once per frame by pump, real input muted meanwhile, same as
|
|
2529
|
+
a replay. One driver at a time."""
|
|
2530
|
+
if (cls.recording is not None or cls.replaying is not None
|
|
2531
|
+
or cls._restore_steps or cls._task is not None):
|
|
2532
|
+
task.fail("engine busy")
|
|
2533
|
+
return task
|
|
2534
|
+
task.start_frame = Melty.frame_count
|
|
2535
|
+
cls._cursor_settled = False
|
|
2536
|
+
cls._last_move_pump = -10
|
|
2537
|
+
cls._glide_queue.clear()
|
|
2538
|
+
task._generator = task.run()
|
|
2539
|
+
cls._task = task
|
|
2540
|
+
cls.status = str(task)
|
|
2541
|
+
request_render()
|
|
2542
|
+
return task
|
|
2543
|
+
|
|
2544
|
+
@classmethod
|
|
2545
|
+
def _step_task(cls):
|
|
2546
|
+
task = cls._task
|
|
2547
|
+
try:
|
|
2548
|
+
next(task._generator)
|
|
2549
|
+
cls.status = str(task)
|
|
2550
|
+
except StopIteration:
|
|
2551
|
+
cls._task = None
|
|
2552
|
+
cls._release_virtual()
|
|
2553
|
+
cls._release_cursor()
|
|
2554
|
+
cls.status = ""
|
|
2555
|
+
if task.error:
|
|
2556
|
+
cls._record_failure(getattr(task, "orchestration", None), task.error)
|
|
2557
|
+
notify(f"{task}: {task.error}", tint=(0.95, 0.6, 0.3, 1.0),
|
|
2558
|
+
tag="orchestrator", urgent=True)
|
|
2559
|
+
else:
|
|
2560
|
+
cls._clear_failure_for(getattr(task, "orchestration", None))
|
|
2561
|
+
cls._record_success(getattr(task, "orchestration", None))
|
|
2562
|
+
notify(f"{task} done", tint=(0.5, 0.9, 0.5, 1.0), tag="orchestrator")
|
|
2563
|
+
task.finish()
|
|
2564
|
+
except Exception as error:
|
|
2565
|
+
cls._task = None
|
|
2566
|
+
cls._release_virtual()
|
|
2567
|
+
cls._release_cursor()
|
|
2568
|
+
cls.status = ""
|
|
2569
|
+
task.fail(f"{type(error).__name__}: {error}")
|
|
2570
|
+
cls._record_failure(getattr(task, "orchestration", None),
|
|
2571
|
+
f"{type(error).__name__}: {error}")
|
|
2572
|
+
notify(f"{task} crashed: {error}", tint=(0.95, 0.35, 0.35, 1.0),
|
|
2573
|
+
tag="orchestrator", urgent=True)
|
|
2574
|
+
|
|
2575
|
+
@classmethod
|
|
2576
|
+
def hotkey(cls):
|
|
2577
|
+
"""Ctrl+Shift+O: stop whatever is running (recording -> stop + trim
|
|
2578
|
+
the hotkey's own keystrokes; replaying/restoring -> abort)."""
|
|
2579
|
+
if cls.recording is not None:
|
|
2580
|
+
cls.stop_recording(via_hotkey=True)
|
|
2581
|
+
elif cls.replaying is not None or cls._restore_steps:
|
|
2582
|
+
cls.abort("hotkey")
|
|
2583
|
+
|
|
2584
|
+
|
|
2585
|
+
# ── the window ───────────────────────────────────────────────────────────
|
|
2586
|
+
|
|
2587
|
+
_window_draw_state = None
|
|
2588
|
+
_last_signature = None
|
|
2589
|
+
|
|
2590
|
+
|
|
2591
|
+
def orchestrator_sync():
|
|
2592
|
+
"""Once per frame from the always-rendering root (beside fast_dock_sync):
|
|
2593
|
+
cue capture while recording, and a repaint of the window's cached tile
|
|
2594
|
+
whenever engine state or the collection changed outside a hovered
|
|
2595
|
+
frame."""
|
|
2596
|
+
global _last_signature
|
|
2597
|
+
Orchestrator.poll_recording()
|
|
2598
|
+
root = getattr(Melty.vis, "root", None)
|
|
2599
|
+
store = getattr(root, "orchestrations", None)
|
|
2600
|
+
preconditions_changed = False
|
|
2601
|
+
if Orchestrator._precondition_watch and \
|
|
2602
|
+
Melty.frame_count - Orchestrator._precondition_frame >= Toggles.Orchestrator.precondition_refresh_frames:
|
|
2603
|
+
Orchestrator._precondition_frame = Melty.frame_count
|
|
2604
|
+
preconditions_changed = Orchestrator.refresh_preconditions(store)
|
|
2605
|
+
rows = tuple((key, orchestration.name, len(orchestration.events),
|
|
2606
|
+
len(orchestration.cues), orchestration.restore_on_finish,
|
|
2607
|
+
len(getattr(orchestration, "overrides", None) or {}))
|
|
2608
|
+
for key, orchestration in store.orchestrations.items()) if store else ()
|
|
2609
|
+
signature = (Orchestrator.status, id(Orchestrator.recording),
|
|
2610
|
+
id(Orchestrator.replaying), Orchestrator._replay_index,
|
|
2611
|
+
id(Orchestrator.last_failure), id(Orchestrator.last_success),
|
|
2612
|
+
bool(Orchestrator._restore_steps), rows)
|
|
2613
|
+
if signature != _last_signature or preconditions_changed:
|
|
2614
|
+
_last_signature = signature
|
|
2615
|
+
# if _window_draw_state is not None and Melty.cache is not None \
|
|
2616
|
+
# and _
|
|
2617
|
+
# window_draw_state._tile_id is not None:
|
|
2618
|
+
# Melty.cache.invalidate_up(_window_draw_state._tile_id, force=True)
|
|
2619
|
+
request_render()
|
|
2620
|
+
|
|
2621
|
+
|
|
2622
|
+
from meltygui.view.orchestration_view import draw_orchestrator
|
|
2623
|
+
draw_orchestrator = window(input_value=None, tint=(1.11, 0.34, 0.38), icon=f'\uf03d', display_name='Orchestrator', initial={'width': 430, 'height': 340})(draw_orchestrator)
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
# The engine sees every real input through this one registration (a tap).
|
|
2627
|
+
set_input_tap(Orchestrator.tap)
|
|
2628
|
+
|
|
2629
|
+
# Execution points publish observable, not-undoable effects here (an actual
|
|
2630
|
+
# window raise, a fired flat_button) - the third cue source.
|
|
2631
|
+
Melty.effect_hook = EffectLedger.note
|
|
2632
|
+
|
|
2633
|
+
# Ctrl+Shift+O anywhere: stop a recording / abort a replay - the mouse is
|
|
2634
|
+
# busy driving (or being driven), so this must not depend on the window.
|
|
2635
|
+
Melty.register_global_hotkey(glfw.KEY_O, glfw.MOD_CONTROL | glfw.MOD_SHIFT,
|
|
2636
|
+
Orchestrator.hotkey, text_focus_ok=True)
|