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,1752 @@
|
|
|
1
|
+
"""change_value(path, to): drive a UI value to a target through REAL input
|
|
2
|
+
events, generalized from recorded demonstrations.
|
|
3
|
+
|
|
4
|
+
change_value(("33071d", "alpha"), 7.5)
|
|
5
|
+
change_value("Lora Window B/[1]/lora_dropout", 0.2)
|
|
6
|
+
change_value(("990001", "name"), "some random new text")
|
|
7
|
+
|
|
8
|
+
The path is a selector (selectors.py) — leaf-anchored, nesting-free. The
|
|
9
|
+
target's EDITOR KIND (the cue's applicability signature) picks an archetype
|
|
10
|
+
executor; the recorded take of that kind supplies the press point as a
|
|
11
|
+
fraction of the demonstrated leaf's rect, re-applied to the resolved
|
|
12
|
+
target's live rect — which is what makes one alpha demonstration drive
|
|
13
|
+
lora_dropout, or any float field anywhere.
|
|
14
|
+
|
|
15
|
+
Archetypes:
|
|
16
|
+
drag (draw_float / draw_int) — press, PROBE a few px to measure the
|
|
17
|
+
live gain from the coalescing undo Change (the held drag updates
|
|
18
|
+
one Change per frame: the undo stack is the sensor), then a
|
|
19
|
+
secant servo to the target. Recorded gain is never trusted;
|
|
20
|
+
per-field speed=, clamps and nonlinearity are absorbed by
|
|
21
|
+
re-measuring every step. A stall (clamp) releases and aborts.
|
|
22
|
+
text (draw_str / draw_text) — click to focus, synthesized clear
|
|
23
|
+
(select-all + delete, End+backspace fallback), payload typed from
|
|
24
|
+
`to` in the event SHAPE a real keystroke produces (key + char),
|
|
25
|
+
exact-match verification with one clear-and-retype retry.
|
|
26
|
+
toggle (draw_bool) — click unless already at the target.
|
|
27
|
+
|
|
28
|
+
Preconditions are a small SOLVER (see `PRECONDITIONS` below): before the
|
|
29
|
+
executor runs, the target must be hittable at its press point. The unmet
|
|
30
|
+
precondition found first along the containment path — a closed window, a
|
|
31
|
+
collapsed ancestor, the target outside its scroll viewport, the press
|
|
32
|
+
point covered by another window — names the EFFECT KINDS that satisfy it;
|
|
33
|
+
every fix's MECHANISM is discovered from recorded takes (an effect cue of
|
|
34
|
+
that kind + the press offset relative to the view it happened on:
|
|
35
|
+
"expand", "raise", "scroll", a WindowMoveChange's header press), never
|
|
36
|
+
coded here, and generalizes to any subject of the same kind by that
|
|
37
|
+
rect-relative offset. Ranking is one number — the disturbance cost table
|
|
38
|
+
`Toggles.Orchestrator.fix_costs` (re-pick the press point 0, raise 1,
|
|
39
|
+
scroll / expand 2, move a window 3, +1 for the cue's anchor window) — the
|
|
40
|
+
cheapest applicable fix runs first, the predicate is re-checked (verify by
|
|
41
|
+
effect, never by assumption), the next candidate runs when it did not
|
|
42
|
+
help. Every attempt is logged into the abort message. A closed WINDOW (the
|
|
43
|
+
dock-row lookup is deferred) or a missing demonstration aborts with the
|
|
44
|
+
unmet need named.
|
|
45
|
+
|
|
46
|
+
Execution: a ValueTask generator stepped once per frame by
|
|
47
|
+
Orchestrator.pump (real input muted, Esc aborts, same contract as replay).
|
|
48
|
+
`task = change_value(...)` returns immediately; task.wait() blocks for test
|
|
49
|
+
harnesses that drive frames themselves.
|
|
50
|
+
"""
|
|
51
|
+
import threading
|
|
52
|
+
import time
|
|
53
|
+
|
|
54
|
+
import meltygui.core.windowing.window_api as glfw
|
|
55
|
+
|
|
56
|
+
from meltygui.core.melty import Melty
|
|
57
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
58
|
+
from meltygui.state.core_undo import UndoManager
|
|
59
|
+
from meltygui.core.automation.orchestration_core import Orchestrator
|
|
60
|
+
from meltygui.core.automation.orchestration_core import cue_get
|
|
61
|
+
from meltygui.core.automation.selector_core import resolve
|
|
62
|
+
from meltygui.core.automation.selector_core import parse
|
|
63
|
+
from meltygui.core.automation.selector_core import format_path
|
|
64
|
+
from meltygui.core.automation.selector_core import ancestor_chain
|
|
65
|
+
from meltygui.core.automation.selector_core import display_name
|
|
66
|
+
from meltygui.core.automation.selector_core import full_name
|
|
67
|
+
from meltygui.core.automation.selector_core import NoMatch
|
|
68
|
+
from meltygui.core.automation.selector_core import Ambiguous
|
|
69
|
+
|
|
70
|
+
# editor kind -> archetype. A take demonstrated on ANY field of a kind
|
|
71
|
+
# drives every field of that kind; add a row here when a new leaf editor
|
|
72
|
+
# gets a up.
|
|
73
|
+
_ARCHETYPE_BY_EDITOR = {"draw_float": "drag", "draw_int": "drag",
|
|
74
|
+
"draw_str": "text", "draw_text": "text",
|
|
75
|
+
"draw_bool": "toggle"}
|
|
76
|
+
|
|
77
|
+
# Archetypes that MUST have a recorded demonstration (the take carries the
|
|
78
|
+
# press fraction and, for text, the event shape). "toggle" degenerates to a
|
|
79
|
+
# centered click and runs take-less.
|
|
80
|
+
_NEEDS_TAKE = {"drag", "text"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _Abort(Exception):
|
|
84
|
+
"""Raised inside a task generator to stop with an honest message."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _PreconditionDone(Exception):
|
|
88
|
+
"""A precondition-only run (ValueTask.until) reached its precondition."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def change_value(path, to, within=None, universe=None, eps=None):
|
|
92
|
+
"""Drive the value at `path` to `to` with real input. Returns the
|
|
93
|
+
ValueTask immediately; the engine pumps it per frame. task.wait() for
|
|
94
|
+
blocking callers, task.error for the outcome."""
|
|
95
|
+
return Orchestrator.submit(ValueTask(path, to, within=within,
|
|
96
|
+
universe=universe, eps=eps))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── the library: takes indexed by what they demonstrate ──────────────────
|
|
100
|
+
|
|
101
|
+
def orchestration_store():
|
|
102
|
+
root = getattr(getattr(Melty, "vis", None), "root", None)
|
|
103
|
+
store = getattr(root, "orchestrations", None)
|
|
104
|
+
return getattr(store, "orchestrations", None) or {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def terminal_cue(take):
|
|
108
|
+
"""The take's last edit-stack Change cue — its demonstrated effect."""
|
|
109
|
+
for cue in reversed(getattr(take, "cues", []) or []):
|
|
110
|
+
if cue_get(cue, "kind") == "Change":
|
|
111
|
+
return cue
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def take_for(archetype):
|
|
116
|
+
"""A recorded take whose terminal cue's editor maps to `archetype` —
|
|
117
|
+
one demonstration per widget kind covers the whole UI."""
|
|
118
|
+
for take in orchestration_store().values():
|
|
119
|
+
cue = terminal_cue(take)
|
|
120
|
+
if cue is not None and _ARCHETYPE_BY_EDITOR.get(cue_get(cue, "editor")) == archetype:
|
|
121
|
+
return take
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def effect_offset(kind, editor=None):
|
|
126
|
+
"""How a demonstration produced effect `kind` ("expand", "raise",
|
|
127
|
+
"scroll", …): the press offset from the top-left of the view the
|
|
128
|
+
effect happened on (the effect cue's press_offset). The control sits
|
|
129
|
+
at the same offset on every subject of that kind — the expand arrow on
|
|
130
|
+
every collection header, the title on every window header — so ONE
|
|
131
|
+
demonstration serves any subject OF THAT KIND: with `editor` (the
|
|
132
|
+
subject's view function) a demonstration from the same editor wins
|
|
133
|
+
(a collapsed WINDOW's chevron is not where a collection's is — the
|
|
134
|
+
collection's offset read as "clipped" on a 32 px window header, 09-01),
|
|
135
|
+
any other demonstration of the effect is the fallback. A take recorded
|
|
136
|
+
before press_offset existed still carries press_frac + leaf_rect (the
|
|
137
|
+
press as a fraction of the subject's rect, and that rect's size):
|
|
138
|
+
frac × size IS the pixel offset — without this, nested gates whose name
|
|
139
|
+
no recorded take matched aborted with "no expand demonstration" (09-01)."""
|
|
140
|
+
matched = fallback = derived = None
|
|
141
|
+
for take in orchestration_store().values():
|
|
142
|
+
for cue in getattr(take, "cues", []) or []:
|
|
143
|
+
if cue_get(cue, "kind") != kind:
|
|
144
|
+
continue
|
|
145
|
+
offset = cue_get(cue, "press_offset")
|
|
146
|
+
if not offset:
|
|
147
|
+
frac, rect = cue_get(cue, "press_frac"), cue_get(cue, "leaf_rect")
|
|
148
|
+
if derived is None and frac and rect and rect[2] > 0 and rect[3] > 0:
|
|
149
|
+
derived = (float(frac[0]) * float(rect[2]),
|
|
150
|
+
float(frac[1]) * float(rect[3]))
|
|
151
|
+
continue
|
|
152
|
+
offset = (float(offset[0]), float(offset[1]))
|
|
153
|
+
if editor is not None and cue_get(cue, "editor") == editor:
|
|
154
|
+
if matched is None:
|
|
155
|
+
matched = offset
|
|
156
|
+
elif fallback is None:
|
|
157
|
+
fallback = offset
|
|
158
|
+
if matched is not None:
|
|
159
|
+
return matched
|
|
160
|
+
if fallback is not None:
|
|
161
|
+
return fallback
|
|
162
|
+
return derived
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def expand_offset(editor=None):
|
|
166
|
+
return effect_offset("expand", editor=editor)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def move_offset():
|
|
170
|
+
"""Where a recorded window move grabbed its window: the WindowMoveChange
|
|
171
|
+
cue anchors on the moved window at its PRE-drag corner and the take's
|
|
172
|
+
events tagged with that cue are relative to it, so the tagged `down`
|
|
173
|
+
event IS the header press offset. One recorded move teaches how to
|
|
174
|
+
grab any window; the drag delta is computed per use (gain is 1 px/px,
|
|
175
|
+
no servo). None when no move was recorded."""
|
|
176
|
+
for take in orchestration_store().values():
|
|
177
|
+
cues = getattr(take, "cues", []) or []
|
|
178
|
+
for index, cue in enumerate(cues):
|
|
179
|
+
if cue_get(cue, "kind") != "WindowMoveChange":
|
|
180
|
+
continue
|
|
181
|
+
for event in getattr(take, "events", []) or []:
|
|
182
|
+
if (len(event) > 5 and event[1] == "down" and event[2] == "left_mouse"
|
|
183
|
+
and event[5] == index):
|
|
184
|
+
return (float(event[3]), float(event[4]))
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def raise_offset():
|
|
189
|
+
"""A raise demonstration's press offset on its window — a recorded
|
|
190
|
+
raise effect first, else the header press of a recorded move (pressing
|
|
191
|
+
a header raises the window too)."""
|
|
192
|
+
return effect_offset("raise") or move_offset()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def fragment_for_gate(gate_name=None):
|
|
196
|
+
"""Fallback for demonstrations recorded before press_offset: a take
|
|
197
|
+
whose expand effect names THIS gate (a fragment for a different
|
|
198
|
+
collection clicks the wrong place), else the legacy bool-Change shape."""
|
|
199
|
+
candidates = []
|
|
200
|
+
for take in orchestration_store().values():
|
|
201
|
+
for cue in reversed(getattr(take, "cues", []) or []):
|
|
202
|
+
kind = cue_get(cue, "kind")
|
|
203
|
+
if kind == "expand":
|
|
204
|
+
candidates.append(("exact" if cue_get(cue, "name") == gate_name else "other",
|
|
205
|
+
take))
|
|
206
|
+
break
|
|
207
|
+
if kind == "Change":
|
|
208
|
+
if (cue_get(cue, "value_type") == "bool"
|
|
209
|
+
and cue_get(cue, "new") in (True, "True")
|
|
210
|
+
and cue_get(cue, "editor") not in _ARCHETYPE_BY_EDITOR):
|
|
211
|
+
candidates.append(("legacy", take))
|
|
212
|
+
break # a leaf-edit take, not an expand take
|
|
213
|
+
for wanted in ("exact", "legacy"): # never an expand fragment for ANOTHER gate
|
|
214
|
+
for grade, take in candidates:
|
|
215
|
+
if grade == wanted:
|
|
216
|
+
return take
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── live-tree helpers (module-level so tests can monkeypatch) ────────────
|
|
221
|
+
|
|
222
|
+
def _editor_of(ds):
|
|
223
|
+
return getattr(getattr(ds, "_view_func", None), "__name__", None)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _current_value(ds):
|
|
227
|
+
return getattr(ds, "_raw_input_value", None)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _live_rect(ds):
|
|
231
|
+
return (getattr(ds, "abs_left", 0) or 0, getattr(ds, "abs_top", 0) or 0,
|
|
232
|
+
getattr(ds, "width", 0) or 0, getattr(ds, "height", 0) or 0)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _reachable(ds):
|
|
236
|
+
"""The one universal guard: is this target actually hittable? The
|
|
237
|
+
STRUCTURAL check is authoritative — a closed window or a collapsed
|
|
238
|
+
ancestor makes the target unreachable whatever else says (a collapsed
|
|
239
|
+
subtree leaves stale hit boxes in the BVH and a stale rect on the
|
|
240
|
+
draw_state, which once passed a BVH-first check and pressed into
|
|
241
|
+
nothing). Geometry must be present too; BVH membership adds nothing
|
|
242
|
+
beyond that."""
|
|
243
|
+
for node in [ds] + ancestor_chain(ds):
|
|
244
|
+
if getattr(node, "closed", False):
|
|
245
|
+
return False
|
|
246
|
+
if node is not ds and getattr(node, "expanded", True) is False:
|
|
247
|
+
return False
|
|
248
|
+
left, top, width, height = _live_rect(ds)
|
|
249
|
+
return width > 0 and height > 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def resolve_recorded(path, within=None, universe=None):
|
|
253
|
+
"""Resolve a RECORDED chain by its minimal unique suffix (capture
|
|
254
|
+
maximal, match minimal): a cue's full chain reaches up to the root
|
|
255
|
+
("Main Window"), which is never a cached tile and so never resolves as
|
|
256
|
+
a segment — the full chain as a path is only as resolvable as its most
|
|
257
|
+
fragile ancestor name. Try suffixes shortest→longest; the first that
|
|
258
|
+
resolves uniquely wins (Ambiguous keeps extending). Returns (ds, suffix
|
|
259
|
+
tried last); raises NoMatch carrying the deepest resolvable prefix so
|
|
260
|
+
the abort names what DID resolve."""
|
|
261
|
+
segments = parse(path)
|
|
262
|
+
last_error = None
|
|
263
|
+
for length in range(1, len(segments) + 1):
|
|
264
|
+
suffix = segments[-length:]
|
|
265
|
+
try:
|
|
266
|
+
return resolve(suffix, within=within, universe=universe), suffix
|
|
267
|
+
except Ambiguous as ambiguous:
|
|
268
|
+
last_error = ambiguous
|
|
269
|
+
continue
|
|
270
|
+
except NoMatch as missing:
|
|
271
|
+
last_error = missing
|
|
272
|
+
break
|
|
273
|
+
if isinstance(last_error, Ambiguous):
|
|
274
|
+
raise last_error
|
|
275
|
+
raise NoMatch(segments, hint=f"leaf {format_path(segments[-1:])} not live")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── preconditions ─────────────────────────────────────────────────────────
|
|
279
|
+
#
|
|
280
|
+
# Each precondition: predicate → (kind, node). `PRECONDITIONS` lists the
|
|
281
|
+
# fix kinds that satisfy each kind; the fixes' mechanisms come from takes
|
|
282
|
+
# (effect_offset / move_offset), the ranking from Toggles.Orchestrator.fix_costs.
|
|
283
|
+
|
|
284
|
+
PRECONDITIONS = {
|
|
285
|
+
"closed": ("open",), # a window in the chain is closed (note: deferred)
|
|
286
|
+
"collapsed": ("expand",), # an ancestor collection is folded
|
|
287
|
+
"outside": ("scroll",), # the target is scrolled out of its viewport
|
|
288
|
+
"clipped": ("re_pick", "scroll"), # the press point is clipped, part of the view shows
|
|
289
|
+
"obscured": ("re_pick", "raise", "move"), # another window covers the press point
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _window_of(ds):
|
|
294
|
+
node, steps = ds, 0
|
|
295
|
+
while node is not None and steps < 64:
|
|
296
|
+
parent = getattr(node, "parent_window", None)
|
|
297
|
+
if parent is None or parent is node:
|
|
298
|
+
return node
|
|
299
|
+
node, steps = parent, steps + 1
|
|
300
|
+
return node
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _hits_at(x, y):
|
|
304
|
+
"""Every live view under (x, y), front first — the same hit test the
|
|
305
|
+
input handler presses into (tests substitute a fake)."""
|
|
306
|
+
try:
|
|
307
|
+
return list(Melty.bvh_query(x, y))
|
|
308
|
+
except Exception:
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _visible_rect(ds):
|
|
313
|
+
"""The target's rect ∩ its clip (what can actually be pressed); empty
|
|
314
|
+
when scrolled out of its viewport."""
|
|
315
|
+
left, top, width, height = _live_rect(ds)
|
|
316
|
+
rect = (left, top, left + width, top + height)
|
|
317
|
+
clip = getattr(ds, "abs_clip_rect", None)
|
|
318
|
+
if clip is None:
|
|
319
|
+
return rect
|
|
320
|
+
return (max(rect[0], clip[0]), max(rect[1], clip[1]),
|
|
321
|
+
min(rect[2], clip[2]), min(rect[3], clip[3]))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _front_window_at(x, y):
|
|
325
|
+
"""The frontmost meltygui window under (x, y). Read from the renderer's
|
|
326
|
+
own paint order (`Melty.paint_ordered_ds`, back → front, rebuilt from
|
|
327
|
+
live state every frame) — NOT the BVH: a blit-cached window that was
|
|
328
|
+
raised or moved over the target keeps its hit boxes / z stamp until
|
|
329
|
+
its wrapper next runs, so the BVH ranked the covered field in front of
|
|
330
|
+
the window covering it and the press went nowhere (a nudge of the
|
|
331
|
+
obscurer "fixed" it, 09-01). The BVH hit test stays the fallback for
|
|
332
|
+
a world without a paint order (tests)."""
|
|
333
|
+
ordered = getattr(Melty, "paint_ordered_ds", None) or []
|
|
334
|
+
for window in reversed(ordered):
|
|
335
|
+
if _window_hidden(window):
|
|
336
|
+
continue
|
|
337
|
+
left, top, right, bottom = _visible_rect(window) # what is DRAWN, not the raw width
|
|
338
|
+
if left <= x <= right and top <= y <= bottom:
|
|
339
|
+
return window
|
|
340
|
+
hits = _hits_at(x, y)
|
|
341
|
+
return _window_of(hits[0]) if hits else None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _window_hidden(window):
|
|
345
|
+
"""A window that paints nothing: closed, culled off-screen, or nested
|
|
346
|
+
inside a closed / COLLAPSED ancestor. A collapsed window itself is NOT
|
|
347
|
+
hidden — its header strip (the expand chevron!) is drawn and hittable
|
|
348
|
+
at its live rect. `abs_closed` reads True for a collapsed window, so
|
|
349
|
+
it must not be the test here: with it, a collapsed 'Loras' was skipped
|
|
350
|
+
by the obscurer scan, the root window won the point under its own
|
|
351
|
+
chevron and the expand fix reported "covered by 'Main Window'" for an
|
|
352
|
+
uncovered button (Lukas 09-01)."""
|
|
353
|
+
node, steps = window, 0
|
|
354
|
+
while node is not None and steps < 64:
|
|
355
|
+
if getattr(node, "closed", False) and getattr(node, "closable", True):
|
|
356
|
+
return True
|
|
357
|
+
if getattr(node, "_hidden_offscreen", False):
|
|
358
|
+
return True
|
|
359
|
+
parent = getattr(node, "parent_window", None)
|
|
360
|
+
if parent is None or parent is node:
|
|
361
|
+
return False
|
|
362
|
+
if getattr(parent, "expanded", True) is False:
|
|
363
|
+
return True # inside a collapsed window: not drawn
|
|
364
|
+
node, steps = parent, steps + 1
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _window_chain(ds):
|
|
369
|
+
"""Every window the target sits in, nearest first, up to its root."""
|
|
370
|
+
chain, node, steps = [], getattr(ds, "parent_window", None), 0
|
|
371
|
+
while node is not None and steps < 64:
|
|
372
|
+
chain.append(node)
|
|
373
|
+
parent = getattr(node, "parent_window", None)
|
|
374
|
+
if parent is None or parent is node:
|
|
375
|
+
break
|
|
376
|
+
node, steps = parent, steps + 1
|
|
377
|
+
return chain or [ds]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _first_unmet(ds, press_point, header=False):
|
|
381
|
+
"""The outermost unmet precondition on the way to `ds` at `press_point`:
|
|
382
|
+
(kind, node) — the node is what the fix acts on (the closed window, the
|
|
383
|
+
folded collection, the target for "outside", the OBSCURING window) —
|
|
384
|
+
or None when the target is hittable there. `header`: the press is a
|
|
385
|
+
header gesture on `ds` itself (raise / move) — a collapsed window with
|
|
386
|
+
no grabbable header is then its OWN collapsed gate (never for an
|
|
387
|
+
expand click, whose control is that very header)."""
|
|
388
|
+
nodes = [ds] + ancestor_chain(ds)
|
|
389
|
+
for node in reversed(nodes): # outermost first
|
|
390
|
+
if getattr(node, "closed", False):
|
|
391
|
+
return ("closed", node)
|
|
392
|
+
if node is not ds and getattr(node, "expanded", True) is False:
|
|
393
|
+
return ("collapsed", node)
|
|
394
|
+
if header and getattr(ds, "expanded", True) is False and not _header_grabbable(ds):
|
|
395
|
+
return ("collapsed", ds)
|
|
396
|
+
left, top, right, bottom = _visible_rect(ds)
|
|
397
|
+
if right - left <= 0 or bottom - top <= 0:
|
|
398
|
+
return ("outside", ds)
|
|
399
|
+
x, y = press_point
|
|
400
|
+
if not (left <= x <= right and top <= y <= bottom):
|
|
401
|
+
return ("clipped", ds) # the press point is clipped
|
|
402
|
+
front = _front_window_at(x, y)
|
|
403
|
+
if front is not None and front is not ds and front not in _window_chain(ds) \
|
|
404
|
+
and front is not _window_of(ds):
|
|
405
|
+
return ("obscured", front)
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _scroll_container(ds):
|
|
410
|
+
"""The nearest ancestor that scrolls (a scroll range, or a scroll offset
|
|
411
|
+
field), else the target's window."""
|
|
412
|
+
for node in ancestor_chain(ds):
|
|
413
|
+
if (getattr(node, "_max_scroll_y", 0) or 0) > 0:
|
|
414
|
+
return node
|
|
415
|
+
if getattr(node, "scroll_offset", None) not in (None, (0, 0)):
|
|
416
|
+
return node
|
|
417
|
+
return _window_of(ds)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _escape_delta(rect, point, margin):
|
|
421
|
+
"""The smallest (dx, dy) moving `rect` so it no longer contains
|
|
422
|
+
`point` (with `margin`); the negation moves the POINT out of the rect."""
|
|
423
|
+
left, top, right, bottom = rect
|
|
424
|
+
x, y = point
|
|
425
|
+
options = [(x + margin - left, 0.0), (x - margin - right, 0.0),
|
|
426
|
+
(0.0, y + margin - top), (0.0, y - margin - bottom)]
|
|
427
|
+
return min(options, key=lambda d: abs(d[0]) + abs(d[1]))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _escape_rect_delta(rect, target, margin):
|
|
431
|
+
"""The smallest (dx, dy) moving `rect` so it no longer overlaps the
|
|
432
|
+
whole `target` rect (plus `margin`) — a move uncovers the CONTROL, not
|
|
433
|
+
just one point on it: a point-sized escape left a 12 px sliver of the
|
|
434
|
+
header free and the next re-check found the rest still covered, the
|
|
435
|
+
windows nudged back and forth and the task gave up (Lukas 09-01: "drag
|
|
436
|
+
windows a little further")."""
|
|
437
|
+
left, top, right, bottom = rect
|
|
438
|
+
t_left, t_top, t_right, t_bottom = target
|
|
439
|
+
options = [(t_right + margin - left, 0.0), (t_left - margin - right, 0.0),
|
|
440
|
+
(0.0, t_bottom + margin - top), (0.0, t_top - margin - bottom)]
|
|
441
|
+
return min(options, key=lambda d: abs(d[0]) + abs(d[1]))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _fix_cost(name, subject_ds, ds):
|
|
445
|
+
costs = Toggles.Orchestrator.fix_costs
|
|
446
|
+
cost = costs.get(name, 99)
|
|
447
|
+
if name == "move" and subject_ds is _window_of(ds):
|
|
448
|
+
cost += costs.get("anchor_window_penalty", 1) # the cues' frame moves with it
|
|
449
|
+
return cost
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ── fix mechanisms (generators; each yields once) ───────────────────────
|
|
453
|
+
|
|
454
|
+
def _header_rect(window_ds):
|
|
455
|
+
"""The strip a header press may land in (re-picks for a window subject
|
|
456
|
+
stay inside it — pressing the body would not raise-by-header / move):
|
|
457
|
+
the header between the chevron margin and the buttons margin. Read
|
|
458
|
+
off the window's VISIBLE rect (rect ∩ clip), never its raw width: a
|
|
459
|
+
collapsed window's `width` read 224 while 100 px of header were drawn
|
|
460
|
+
and hittable, so the "safe" centre landed in empty space right of it
|
|
461
|
+
(Lukas 09-01)."""
|
|
462
|
+
left, top, right, _bottom = _visible_rect(window_ds)
|
|
463
|
+
width = max(0.0, right - left)
|
|
464
|
+
safe_left = min(Toggles.Orchestrator.header_safe_left_px, max(4.0, width / 2.0))
|
|
465
|
+
safe_right = max(safe_left + 1.0, width - Toggles.Orchestrator.header_safe_right_px)
|
|
466
|
+
return (left + safe_left, top + 2.0, left + safe_right,
|
|
467
|
+
top + Toggles.Orchestrator.header_height_px)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _header_grabbable(window_ds):
|
|
471
|
+
"""Whether the window's visible header has ROOM for a press between
|
|
472
|
+
the chevron margin and the buttons margin. A collapsed window shrunk
|
|
473
|
+
to its buttons has none — a header press there hits a button or
|
|
474
|
+
nothing, so for a header gesture it counts as a collapsed gate: expand
|
|
475
|
+
it first (Lukas 09-01, the move on a collapsed Loras)."""
|
|
476
|
+
left, _top, right, _bottom = _visible_rect(window_ds)
|
|
477
|
+
return (right - left) >= (Toggles.Orchestrator.header_safe_left_px
|
|
478
|
+
+ Toggles.Orchestrator.header_safe_right_px)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _header_point(window_ds, offset):
|
|
482
|
+
"""A press point on `window_ds` for a header press (raise / move): the
|
|
483
|
+
CENTER of the header's safe strip. The demonstrated offset is not used
|
|
484
|
+
for position — where that user grabbed THAT window means nothing here:
|
|
485
|
+
its x landed on the collapse chevron once (folding the window instead
|
|
486
|
+
of raising it), its y a pixel above the visible top edge, which sits
|
|
487
|
+
inside abs_top (Lukas 09-01). The demo still proves the mechanism."""
|
|
488
|
+
left, top, right, bottom = _header_rect(window_ds)
|
|
489
|
+
return (left + right) / 2.0, (top + bottom) / 2.0
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _point_of(point):
|
|
493
|
+
"""A gesture point is a (x, y) tuple or a CALLABLE returning one — the
|
|
494
|
+
live form is re-read every pump of the approach and once more at the
|
|
495
|
+
press, so a target that moves while the cursor travels (the third
|
|
496
|
+
expand of a nested set: the second's reflow shifts it, 09-01) is
|
|
497
|
+
still hit where it IS, not where it was when the gesture was planned."""
|
|
498
|
+
return point() if callable(point) else point
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _tracking(x, y, anchor):
|
|
502
|
+
"""A live point that keeps the offset of (x, y) — a re-picked press spot
|
|
503
|
+
_satisfy settled on — from the live `anchor` point, so a target that
|
|
504
|
+
moves carries the re-pick along."""
|
|
505
|
+
ax, ay = anchor()
|
|
506
|
+
dx, dy = x - ax, y - ay
|
|
507
|
+
return lambda: (anchor()[0] + dx, anchor()[1] + dy)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _click_at(point, ds=None):
|
|
511
|
+
"""A click on `ds` (a header's imgui arrow_button, a flat_button). imgui
|
|
512
|
+
reacts only to an item it SUBMITTED that frame: a button activates on
|
|
513
|
+
the press frame and fires on the release frame, and drops between if
|
|
514
|
+
it misses one — so the target's tile is forced live for the press, the
|
|
515
|
+
frame between and the release (Melty skips the hover invalidate on
|
|
516
|
+
press frames, and a blit-served tile never sees the click while the
|
|
517
|
+
handler's own subscriptions still do: the "flaky" gate clicks, 09-01)."""
|
|
518
|
+
yield from _settle_at(point, ds)
|
|
519
|
+
x, y = _point_of(point)
|
|
520
|
+
_press(x, y, ds)
|
|
521
|
+
yield
|
|
522
|
+
_render_live(ds)
|
|
523
|
+
yield
|
|
524
|
+
_release(x, y, ds)
|
|
525
|
+
yield
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _drag_by(point, dx, dy, ds=None):
|
|
529
|
+
yield from _settle_at(point, ds)
|
|
530
|
+
x, y = _point_of(point)
|
|
531
|
+
_press(x, y, ds)
|
|
532
|
+
yield
|
|
533
|
+
duration = glide_seconds(abs(dx) + abs(dy))
|
|
534
|
+
started = time.monotonic()
|
|
535
|
+
while True:
|
|
536
|
+
t = min(1.0, (time.monotonic() - started) / duration)
|
|
537
|
+
_move(x + dx * t, y + dy * t)
|
|
538
|
+
yield
|
|
539
|
+
if t >= 1.0:
|
|
540
|
+
break
|
|
541
|
+
_release(x + dx, y + dy, ds)
|
|
542
|
+
yield
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _gate_press_rect(point):
|
|
546
|
+
"""Where a re-pick may land for a gate's control: the expand chevron is
|
|
547
|
+
a few px wide, so a re-pick must stay ON it — anywhere else on the
|
|
548
|
+
header row is not the control. A press point that is covered inside
|
|
549
|
+
this radius has no re-pick and falls through to raise / move."""
|
|
550
|
+
radius = Toggles.Orchestrator.gate_hit_radius_px
|
|
551
|
+
x, y = point
|
|
552
|
+
return (x - radius, y - radius, x + radius, y + radius)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _fragment_press_point(fragment, window_ds):
|
|
556
|
+
"""The screen point a gate fragment's first press lands on once its
|
|
557
|
+
anchors are rebound to `window_ds` (what _replay_fragment does), or
|
|
558
|
+
None for a fragment without a press."""
|
|
559
|
+
origin = None
|
|
560
|
+
if window_ds is not None:
|
|
561
|
+
left = getattr(window_ds, "abs_left", None)
|
|
562
|
+
top = getattr(window_ds, "abs_top", None)
|
|
563
|
+
if left is not None and top is not None:
|
|
564
|
+
origin = (float(left), float(top))
|
|
565
|
+
for event in getattr(fragment, "events", []) or []:
|
|
566
|
+
if event[1] == "down" and event[2] == "left_mouse":
|
|
567
|
+
x, y = float(event[3]), float(event[4])
|
|
568
|
+
if len(event) > 5 and origin is not None:
|
|
569
|
+
return (x + origin[0], y + origin[1])
|
|
570
|
+
return (x, y)
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _fix_expand(task, node, ds):
|
|
575
|
+
"""Open a collapsed collection by pressing its expand control. The
|
|
576
|
+
control's press point is a SUB-GOAL under the same rules as the target
|
|
577
|
+
(preconditions apply recursively — Lukas 09-01: a window sitting over
|
|
578
|
+
the collapse button is answered by a raise of the gate's window or a
|
|
579
|
+
move of the obscurer BEFORE the click, exactly as an obscurer's own
|
|
580
|
+
covered header is for a move), so a gate the user could not click
|
|
581
|
+
either is opened the way the user would open it."""
|
|
582
|
+
offset = expand_offset(editor=_editor_of(node))
|
|
583
|
+
if offset is not None:
|
|
584
|
+
def live_point():
|
|
585
|
+
left, top, _w, _h = _live_rect(node)
|
|
586
|
+
return left + offset[0], top + offset[1]
|
|
587
|
+
# a click that did not open the gate gets ONE more go after the
|
|
588
|
+
# control's press is re-satisfied (a cover that slid back, a
|
|
589
|
+
# tile that missed the press frame)
|
|
590
|
+
for attempt in range(2):
|
|
591
|
+
x, y = yield from task._satisfy(node, live_point, depth=task._depth + 1,
|
|
592
|
+
rect=lambda: _gate_press_rect(live_point()))
|
|
593
|
+
yield from _click_at(_tracking(x, y, live_point), node)
|
|
594
|
+
for _ in range(Toggles.Orchestrator.cue_wait_frames):
|
|
595
|
+
if getattr(node, "expanded", True) is not False:
|
|
596
|
+
return
|
|
597
|
+
yield
|
|
598
|
+
else:
|
|
599
|
+
fragment = fragment_for_gate(display_name(node))
|
|
600
|
+
if fragment is None:
|
|
601
|
+
raise _Abort(f"'{display_name(node)}' is collapsed and no "
|
|
602
|
+
f"expand demonstration is recorded")
|
|
603
|
+
window_ds = getattr(node, "parent_window", None)
|
|
604
|
+
if _fragment_press_point(fragment, window_ds) is not None:
|
|
605
|
+
# the fragment replays relative to its window's LIVE origin, so
|
|
606
|
+
# its press point is read live (a move of the window carries
|
|
607
|
+
# it along); there is no re-pick (a zero-size rect) — a covered
|
|
608
|
+
# press point goes straight to the raise / move candidates
|
|
609
|
+
press_fn = lambda: _fragment_press_point(fragment, window_ds)
|
|
610
|
+
yield from task._satisfy(node, press_fn, depth=task._depth + 1,
|
|
611
|
+
rect=lambda: press_fn() * 2)
|
|
612
|
+
yield from _replay_fragment(fragment, window_ds)
|
|
613
|
+
for _ in range(Toggles.Orchestrator.cue_wait_frames):
|
|
614
|
+
if getattr(node, "expanded", True) is not False:
|
|
615
|
+
break
|
|
616
|
+
yield
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _fix_raise(task, window_ds, ds):
|
|
620
|
+
offset = raise_offset()
|
|
621
|
+
if offset is None:
|
|
622
|
+
raise _Abort("no raise demonstration recorded — record one (click any "
|
|
623
|
+
"window's header)")
|
|
624
|
+
# sub-goal: the header row must be hittable (re-picked inside the strip)
|
|
625
|
+
x, y = yield from task._satisfy(window_ds, lambda: _header_point(window_ds, offset),
|
|
626
|
+
depth=task._depth + 1,
|
|
627
|
+
rect=lambda: _header_rect(window_ds), header=True)
|
|
628
|
+
point = _tracking(x, y, lambda: _header_point(window_ds, offset))
|
|
629
|
+
yield from _click_at(point, window_ds)
|
|
630
|
+
# Verified by STATE: the window is in front at its header. The
|
|
631
|
+
# re-check after a fix only sees the unmet change, and a click that
|
|
632
|
+
# raised some OTHER window got it ok - "raise 'Loras' (ok)" with
|
|
633
|
+
# no raise effect anywhere, and the expand click that followed landed
|
|
634
|
+
# on the window still covering the chevron (Lukas 09-01).
|
|
635
|
+
for _ in range(Toggles.Orchestrator.layout_settle_pumps):
|
|
636
|
+
px, py = _point_of(point)
|
|
637
|
+
if _front_window_at(px, py) is window_ds:
|
|
638
|
+
return
|
|
639
|
+
yield
|
|
640
|
+
raise _Abort(f"'{display_name(window_ds)}' did not come to front")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _fix_move(task, mover_ds, ds, point, rect=None):
|
|
644
|
+
"""Move `mover_ds` (the obscurer, or the target's own window) by the
|
|
645
|
+
smallest delta that uncovers the target's press RECT — the re-pick
|
|
646
|
+
rect when the caller gave one (a gate's chevron zone, a header strip),
|
|
647
|
+
else the target's visible rect — and always at least `point`."""
|
|
648
|
+
offset = move_offset()
|
|
649
|
+
if offset is None:
|
|
650
|
+
raise _Abort("no window-move demonstration recorded — record one (drag "
|
|
651
|
+
"any window by its header)")
|
|
652
|
+
obscurer = task._unmet[1]
|
|
653
|
+
left, top, width, height = _live_rect(obscurer)
|
|
654
|
+
margin = Toggles.Orchestrator.uncover_margin_px
|
|
655
|
+
# What the move must clear: the subject's WHOLE visible rect, plus any
|
|
656
|
+
# re-pick rect and the point. Clearing only the re-pick box (a gate's
|
|
657
|
+
# ±6 px chevron zone) parked the obscurer a hair outside it, and the
|
|
658
|
+
# next re-pick / layout slop put the chevron back over its edge -
|
|
659
|
+
# "it didn't drag it far enough" (Lukas 09-01). A person drags the
|
|
660
|
+
# window off the thing, not off the pixel.
|
|
661
|
+
target = _visible_rect(ds)
|
|
662
|
+
extra = rect() if callable(rect) else rect
|
|
663
|
+
if extra is not None:
|
|
664
|
+
target = (min(target[0], extra[0]), min(target[1], extra[1]),
|
|
665
|
+
max(target[2], extra[2]), max(target[3], extra[3]))
|
|
666
|
+
x, y = point
|
|
667
|
+
target = (min(target[0], x), min(target[1], y), max(target[2], x), max(target[3], y))
|
|
668
|
+
dx, dy = _escape_rect_delta((left, top, left + width, top + height), target, margin)
|
|
669
|
+
if mover_ds is not obscurer:
|
|
670
|
+
dx, dy = -dx, -dy # the point rides with the window
|
|
671
|
+
# Move by GEOMETRY, like a replayed move - the mover's corner must
|
|
672
|
+
# travel by the delta. The re-check after a fix only sees the unmet
|
|
673
|
+
# CHANGE - and a header press RAISES its window, so a drag whose grab
|
|
674
|
+
# never took still reshuffled the pile ("obscured by A" became
|
|
675
|
+
# "obscured by B"), read as ok, and the solver moved A and B in circles
|
|
676
|
+
# for hundreds of rounds without a window moving an inch (Lukas 09-01).
|
|
677
|
+
# A grab that does not take gets ONE more try after re-settling.
|
|
678
|
+
tolerance = Toggles.Orchestrator.move_tolerance_px
|
|
679
|
+
for attempt in range(2):
|
|
680
|
+
x, y = yield from task._satisfy(mover_ds, lambda: _header_point(mover_ds, offset),
|
|
681
|
+
depth=task._depth + 1,
|
|
682
|
+
rect=lambda: _header_rect(mover_ds), header=True)
|
|
683
|
+
before = _live_rect(mover_ds)[:2]
|
|
684
|
+
yield from _drag_by(_tracking(x, y, lambda: _header_point(mover_ds, offset)),
|
|
685
|
+
dx, dy, mover_ds)
|
|
686
|
+
yield from _wait_layout(mover_ds)
|
|
687
|
+
after = _live_rect(mover_ds)[:2]
|
|
688
|
+
moved = (after[0] - before[0], after[1] - before[1])
|
|
689
|
+
if abs(moved[0] - dx) <= tolerance and abs(moved[1] - dy) <= tolerance:
|
|
690
|
+
return
|
|
691
|
+
if abs(moved[0]) + abs(moved[1]) > tolerance:
|
|
692
|
+
return # it moved, short of the delta: the re-check s
|
|
693
|
+
raise _Abort(f"'{display_name(mover_ds)}' did not move (header drag by "
|
|
694
|
+
f"({dx:.0f}, {dy:.0f}) not taken)")
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _fix_scroll(task, ds, point_fn):
|
|
698
|
+
container = _scroll_container(ds)
|
|
699
|
+
cl, ct, cr, cb = _visible_rect(container)
|
|
700
|
+
if cr - cl <= 0 or cb - ct <= 0:
|
|
701
|
+
cl, ct, cw, ch = _live_rect(container)
|
|
702
|
+
cr, cb = cl + cw, ct + ch
|
|
703
|
+
cx, cy = (cl + cr) / 2.0, (ct + cb) / 2.0
|
|
704
|
+
# the wheel scrolls to whatever is under the cursor: the viewport's
|
|
705
|
+
# centre is a sub-goal too (a window over it is raised / moved, or a
|
|
706
|
+
# re-pick inside the target's visible rect finds an open spot)
|
|
707
|
+
cx, cy = yield from task._satisfy(container, lambda: (cx, cy), depth=task._depth + 1,
|
|
708
|
+
rect=(cl, ct, cr, cb))
|
|
709
|
+
_left, top, _w, height = _live_rect(ds)
|
|
710
|
+
# wheel +1 moves content DOWN (reveals the top) - see the wrapper's
|
|
711
|
+
# scroll block in core_render; a target below the viewport needs
|
|
712
|
+
# negative wheel motion. Which side it is on: the target's own clip
|
|
713
|
+
# rect is rect ∩ viewport, so its bottom sits ABOVE the target's
|
|
714
|
+
# middle exactly when the target is below the viewport.
|
|
715
|
+
clip = getattr(ds, "abs_clip_rect", None)
|
|
716
|
+
clip_bottom = clip[3] if clip is not None else cy
|
|
717
|
+
direction = -1.0 if clip_bottom < top + height / 2.0 else 1.0
|
|
718
|
+
yield from _settle_at((cx, cy))
|
|
719
|
+
for _ in range(Toggles.Orchestrator.scroll_attempts):
|
|
720
|
+
Orchestrator._inject((0.0, "change", "scroll_y", direction))
|
|
721
|
+
yield
|
|
722
|
+
yield
|
|
723
|
+
if (_first_unmet(ds, point_fn()) or (None,))[0] != "outside":
|
|
724
|
+
return
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _candidates(task, unmet, ds, point_fn, rect=None):
|
|
728
|
+
"""[(cost, label, generator)] for one unmet precondition, cheapest
|
|
729
|
+
first. A fix whose mechanism is not recorded is listed too — running
|
|
730
|
+
it aborts naming the missing demonstration — unless a cheaper recorded
|
|
731
|
+
one exists, so the abort names the FIRST thing worth recording."""
|
|
732
|
+
kind, node = unmet
|
|
733
|
+
point = point_fn()
|
|
734
|
+
out = []
|
|
735
|
+
if kind == "closed":
|
|
736
|
+
raise _Abort(f"window '{display_name(node)}' is closed")
|
|
737
|
+
if kind == "collapsed":
|
|
738
|
+
out.append((_fix_cost("expand", node, ds), f"expand '{display_name(node)}'",
|
|
739
|
+
lambda: _fix_expand(task, node, ds)))
|
|
740
|
+
elif kind in ("outside", "clipped"):
|
|
741
|
+
if kind == "clipped":
|
|
742
|
+
out.append((_fix_cost("re_pick", ds, ds), "re-pick the press point",
|
|
743
|
+
lambda: task._re_pick(ds, rect)))
|
|
744
|
+
out.append((_fix_cost("scroll", node, ds),
|
|
745
|
+
f"scroll '{display_name(_scroll_container(ds))}'",
|
|
746
|
+
lambda: _fix_scroll(task, ds, point_fn)))
|
|
747
|
+
elif kind == "obscured":
|
|
748
|
+
window = _window_of(ds)
|
|
749
|
+
out.append((_fix_cost("re_pick", ds, ds), "re-pick the press point",
|
|
750
|
+
lambda: task._re_pick(ds, rect)))
|
|
751
|
+
out.append((_fix_cost("raise", window, ds), f"raise '{display_name(window)}'",
|
|
752
|
+
lambda: _fix_raise(task, window, ds)))
|
|
753
|
+
for mover in (node, window):
|
|
754
|
+
if getattr(mover, "closable", True) is False:
|
|
755
|
+
continue
|
|
756
|
+
out.append((_fix_cost("move", mover, ds), f"move '{display_name(mover)}'",
|
|
757
|
+
lambda m=mover: _fix_move(task, m, ds, point, rect)))
|
|
758
|
+
out.sort(key=lambda c: c[0])
|
|
759
|
+
return [c for c in out if c[0] <= Toggles.Orchestrator.max_disturbance]
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _live_change_on(ds, since_frame):
|
|
763
|
+
"""Newest edit-stack Change targeting `ds` recorded at/after
|
|
764
|
+
`since_frame`. A held drag COALESCES — the same Change's `new` advances
|
|
765
|
+
per frame — which is what makes mid-gesture reading possible."""
|
|
766
|
+
for change in reversed(UndoManager.stack.history):
|
|
767
|
+
if change.frame < since_frame:
|
|
768
|
+
break
|
|
769
|
+
if change.draw_state is ds:
|
|
770
|
+
return change
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
# ── injection shorthand (absolute coordinates) ───────────────────────────
|
|
775
|
+
|
|
776
|
+
def _wait_layout(ds, since_frame=None):
|
|
777
|
+
"""After a fix moved things (an expand reflowed the rows below it, a
|
|
778
|
+
scroll, a window move): wait until `ds` has been laid out again — its
|
|
779
|
+
wrapper ran after `since_frame` (draw_state.last_seen) — and its rect
|
|
780
|
+
held still for two consecutive frames, so the press point is read
|
|
781
|
+
from LIVE geometry. Read on the frame right after an expand, a leaf
|
|
782
|
+
still carried its pre-expand rect and the retargeted click landed on
|
|
783
|
+
the collection header above it (09-01). Bounded by layout_settle_pumps."""
|
|
784
|
+
last_rect = None
|
|
785
|
+
for _ in range(Toggles.Orchestrator.layout_settle_pumps):
|
|
786
|
+
rect = _live_rect(ds)
|
|
787
|
+
seen = getattr(ds, "last_seen", None)
|
|
788
|
+
laid_out = (since_frame is None or seen is None
|
|
789
|
+
or (isinstance(seen, (int, float)) and seen > since_frame))
|
|
790
|
+
if laid_out and rect == last_rect:
|
|
791
|
+
return
|
|
792
|
+
last_rect = rect
|
|
793
|
+
yield
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _wait_seconds(seconds):
|
|
797
|
+
"""Hold for a recorded pause (wall clock; yields frames meanwhile)."""
|
|
798
|
+
deadline = time.monotonic() + max(0.0, seconds)
|
|
799
|
+
while time.monotonic() < deadline:
|
|
800
|
+
yield
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def glide_seconds(distance):
|
|
804
|
+
"""How long a synthesized move of `distance` px takes: a human hand's
|
|
805
|
+
speed (Toggles.Orchestrator.glide_px_per_second), clamped."""
|
|
806
|
+
return max(Toggles.Orchestrator.glide_min_s,
|
|
807
|
+
min(Toggles.Orchestrator.glide_max_s,
|
|
808
|
+
distance / max(1.0, Toggles.Orchestrator.glide_px_per_second)))
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _glide(start_x, start_y, point):
|
|
812
|
+
"""Eased, WALL-CLOCK paced travel from (start_x, start_y) to `point`:
|
|
813
|
+
one move per pump along a smoothstep curve until glide_seconds elapse.
|
|
814
|
+
`point` may be live (see _point_of): the curve re-aims at the target's
|
|
815
|
+
CURRENT position every pump, so it lands on a target that moved."""
|
|
816
|
+
x, y = _point_of(point)
|
|
817
|
+
distance = ((x - start_x) ** 2 + (y - start_y) ** 2) ** 0.5
|
|
818
|
+
if distance < 0.5:
|
|
819
|
+
_move(x, y)
|
|
820
|
+
yield
|
|
821
|
+
return
|
|
822
|
+
duration = glide_seconds(distance)
|
|
823
|
+
started = time.monotonic()
|
|
824
|
+
while True:
|
|
825
|
+
x, y = _point_of(point)
|
|
826
|
+
fraction = min(1.0, (time.monotonic() - started) / duration)
|
|
827
|
+
eased = fraction * fraction * (3.0 - 2.0 * fraction)
|
|
828
|
+
_move(start_x + (x - start_x) * eased, start_y + (y - start_y) * eased)
|
|
829
|
+
yield
|
|
830
|
+
if fraction >= 1.0:
|
|
831
|
+
break
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _settle_at(point, ds=None):
|
|
835
|
+
"""Move the virtual cursor to (x, y) and wait until a press there lands
|
|
836
|
+
on what is under it: the engine's settle rule (Orchestrator.press_ready
|
|
837
|
+
— hover registered by a render after stamp_io saw the move, no real
|
|
838
|
+
button held), AND the target's own tile reporting hover
|
|
839
|
+
(`_bounding_hovered`, set only when its wrapper RUNS — i.e. the tile has
|
|
840
|
+
left the blit cache and its imgui widget is being submitted live; a
|
|
841
|
+
press on a cached tile reaches only the enclosing window's move handle).
|
|
842
|
+
Bounded wait: a view that never reports hover (no bounding tracking)
|
|
843
|
+
proceeds after SETTLE_HOVER_PUMPS."""
|
|
844
|
+
# Glide from wherever the virtual cursor is to (x, y) over several
|
|
845
|
+
# pumps (eased) rather than teleporting: the real pointer travels, hover
|
|
846
|
+
# edges fire naturally along the way, and the progress is visible.
|
|
847
|
+
start_x, start_y = Orchestrator._virtual_x, Orchestrator._virtual_y
|
|
848
|
+
if not Orchestrator._cursor_settled:
|
|
849
|
+
start_x, start_y = _point_of(point) # first move of the run, no origin to glide from
|
|
850
|
+
yield from _glide(start_x, start_y, point)
|
|
851
|
+
while True:
|
|
852
|
+
x, y = _point_of(point)
|
|
853
|
+
if abs(x - Orchestrator._virtual_x) + abs(y - Orchestrator._virtual_y) > 0.5:
|
|
854
|
+
_move(x, y) # the target moved since the glide landed
|
|
855
|
+
yield
|
|
856
|
+
continue
|
|
857
|
+
if Orchestrator.press_ready((x, y)):
|
|
858
|
+
break
|
|
859
|
+
yield
|
|
860
|
+
waited = 0
|
|
861
|
+
while (ds is not None and not getattr(ds, "_bounding_hovered", False)
|
|
862
|
+
and waited < Toggles.Orchestrator.settle_hover_pumps):
|
|
863
|
+
waited += 1
|
|
864
|
+
yield
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# change_value's own injections bypass the engine's continuous-mouse layer
|
|
868
|
+
# (glide=False): every move here is already paced (_glide, see servo) and
|
|
869
|
+
# every press already settled. Routed through that layer, an eased step
|
|
870
|
+
# over its teleport threshold spawned a SECONDARY glide and the press /
|
|
871
|
+
# release queued behind it: the press landed late, and a glide running
|
|
872
|
+
# while a button was virtually held dragged the cursor by the header.
|
|
873
|
+
def _press(x, y, ds=None):
|
|
874
|
+
"""Press — with the target's tile forced LIVE on the press frame: imgui
|
|
875
|
+
only activates an item it submitted the frame it saw the click, and a
|
|
876
|
+
press frame otherwise serves the tile from the blit cache (the hover
|
|
877
|
+
invalidate is suppressed on press frames, see apply_move_to_front).
|
|
878
|
+
This is also what makes a press on a NON-front window work — the
|
|
879
|
+
press raises it and the raise reshuffles tiles that frame — so
|
|
880
|
+
front-ness is deliberately not a precondition: the Orchestrator's own
|
|
881
|
+
window (Play was just pressed in it) is in front of every target, and
|
|
882
|
+
a "must be front" gate could never be met (Lukas 09-01)."""
|
|
883
|
+
_render_live(ds)
|
|
884
|
+
Orchestrator._inject((0.0, "down", "left_mouse", x, y), glide=False)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _render_live(ds):
|
|
888
|
+
"""Dirty the target's tile so the coming frame submits its imgui item."""
|
|
889
|
+
invalidate = getattr(ds, "invalidate", None) if ds is not None else None
|
|
890
|
+
if callable(invalidate):
|
|
891
|
+
try:
|
|
892
|
+
invalidate()
|
|
893
|
+
except Exception:
|
|
894
|
+
pass
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _move(x, y):
|
|
898
|
+
Orchestrator._inject((0.0, "move", x, y), glide=False)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _release(x, y, ds=None):
|
|
902
|
+
_render_live(ds) # a button FIRES on the release frame
|
|
903
|
+
Orchestrator._inject((0.0, "up", "left_mouse", x, y), glide=False)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _key(key, mods=0):
|
|
907
|
+
Orchestrator._inject((0.0, "key", key, mods))
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _char(codepoint):
|
|
911
|
+
Orchestrator._inject((0.0, "char", codepoint))
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _leaf_point(ds, take, task=None):
|
|
915
|
+
"""Press point: the caller's explicit point when it has one (a replay
|
|
916
|
+
remap presses where the tape recorded — the cursor is already there),
|
|
917
|
+
else the take's demonstrated press as a FRACTION of its leaf's rect,
|
|
918
|
+
re-applied to THIS leaf's live rect (the lora_dropout generalization).
|
|
919
|
+
No take / no press -> the leaf's center."""
|
|
920
|
+
if task is not None and task.press_point is not None:
|
|
921
|
+
return task.press_point
|
|
922
|
+
frac = None
|
|
923
|
+
if task is not None and getattr(task, "press_frac", None) is not None:
|
|
924
|
+
frac = task.press_frac # the cue's OWN press, first
|
|
925
|
+
if frac is None and take is not None:
|
|
926
|
+
cue = terminal_cue(take)
|
|
927
|
+
frac = cue_get(cue, "press_frac") if cue is not None else None
|
|
928
|
+
frac = frac or (0.5, 0.5)
|
|
929
|
+
left, top, width, height = _live_rect(ds)
|
|
930
|
+
return (left + frac[0] * width, top + frac[1] * height)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _replay_fragment(fragment, window_ds):
|
|
934
|
+
"""Replay a gate fragment with its anchors rebound to `window_ds`'s
|
|
935
|
+
live origin — the recorded window-relative geometry lands in the
|
|
936
|
+
TARGET window. Runs at 2x the configured replay speed (gates are means,
|
|
937
|
+
not the demonstration)."""
|
|
938
|
+
origin = None
|
|
939
|
+
if window_ds is not None:
|
|
940
|
+
left = getattr(window_ds, "abs_left", None)
|
|
941
|
+
top = getattr(window_ds, "abs_top", None)
|
|
942
|
+
if left is not None and top is not None:
|
|
943
|
+
origin = (left, top)
|
|
944
|
+
speed = max(0.05, Toggles.Orchestrator.replay_speed) * 2.0
|
|
945
|
+
t0 = time.monotonic()
|
|
946
|
+
for event in fragment.events:
|
|
947
|
+
while event[0] > (time.monotonic() - t0) * speed:
|
|
948
|
+
yield
|
|
949
|
+
_inject_rebased(event, origin)
|
|
950
|
+
# the fragment's events may have been QUEUED FOR a continuous-mouse
|
|
951
|
+
# glide (a reused take injects far from the cursor): hold until they
|
|
952
|
+
# have actually landed, so the caller's wait for the effect starts
|
|
953
|
+
# after the click, not before it
|
|
954
|
+
while Orchestrator._glide_queue:
|
|
955
|
+
yield
|
|
956
|
+
yield
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _inject_rebased(event, origin):
|
|
960
|
+
kind = event[1]
|
|
961
|
+
if origin is not None and kind in ("move", "down", "up"):
|
|
962
|
+
slot = 2 if kind == "move" else 3
|
|
963
|
+
if len(event) > slot + 2: # relativized: rebase to origin
|
|
964
|
+
Orchestrator._inject(event[:slot] + (event[slot] + origin[0],
|
|
965
|
+
event[slot + 1] + origin[1]))
|
|
966
|
+
return
|
|
967
|
+
Orchestrator._inject(event)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
# ── the task ─────────────────────────────────────────────────────────────
|
|
971
|
+
|
|
972
|
+
class ValueTask:
|
|
973
|
+
"""One change_value run: a generator stepped per frame by the engine.
|
|
974
|
+
error is None on success; result is the verified final value."""
|
|
975
|
+
|
|
976
|
+
def __init__(self, path, to, within=None, universe=None, eps=None):
|
|
977
|
+
self.path = path
|
|
978
|
+
self.to = to
|
|
979
|
+
self.pacing = {} # predefined pauses to honour (a replay remap sets these)
|
|
980
|
+
self.press_point = None # explicit press point (a replay remap: the recorded press)
|
|
981
|
+
self.gates_only = False # replay without override: open the gates where the tape presses
|
|
982
|
+
self.within = within
|
|
983
|
+
self.universe = universe
|
|
984
|
+
self.eps = eps
|
|
985
|
+
self.error = None
|
|
986
|
+
self.result = None
|
|
987
|
+
self.start_frame = 0
|
|
988
|
+
self.trace_target = None
|
|
989
|
+
self._verify_eps = 0.0
|
|
990
|
+
self._skip_verify = False
|
|
991
|
+
self._generator = None
|
|
992
|
+
self._done = threading.Event()
|
|
993
|
+
self._take = None
|
|
994
|
+
self._unmet = None
|
|
995
|
+
self._depth = 0
|
|
996
|
+
self._picked = None
|
|
997
|
+
# Fix labels running up the sub-goal chain. A fix's own sub-goal
|
|
998
|
+
# never re-offers the fix: raising W needs W's header hittable,
|
|
999
|
+
# and with the wrong tests the cheapest candidate for THAT was
|
|
1000
|
+
# "raise W" again - seven levels of the same raise until max_depth
|
|
1001
|
+
# stopped them, for every window in the tree, before the fix that
|
|
1002
|
+
# actually helps was ever tried at depth 1 (Lukas 09-01).
|
|
1003
|
+
self._active_fixes = []
|
|
1004
|
+
self.attempts = [] # "fix (result)" log - the abort message lists it
|
|
1005
|
+
self._last_fix_frame = None # Melty.frame_count when the last fix finished (layout wait)
|
|
1006
|
+
# A precondition-only run (the orchestrator window's per-precondition
|
|
1007
|
+
# run chip): satisfy preconditions up to and including the one with
|
|
1008
|
+
# this key (see precondition.py), then stop - no value change, no
|
|
1009
|
+
# verify. Outer preconditions along the way are satisfied too: the
|
|
1010
|
+
# solver is outermost-first, so "run the third one" performs the
|
|
1011
|
+
# first two, exactly as the full command would.
|
|
1012
|
+
self.until = None
|
|
1013
|
+
self.orchestration = None # the take this run belongs to (failure / success rows)
|
|
1014
|
+
# The command's press target: "leaf" (a value editor, the press at
|
|
1015
|
+
# the demonstrated fraction of its rect) or "header" (a WINDOW's
|
|
1016
|
+
# header strip - the move archetype; `to` is then the (dx, dy) the
|
|
1017
|
+
# window must travel). The precondition ladder is the same either
|
|
1018
|
+
# way; only the press point and the re-pick rect differ.
|
|
1019
|
+
self.gesture = "leaf"
|
|
1020
|
+
# The press as a FRACTION of the target's rect, from the cue that
|
|
1021
|
+
# demonstrated it - the press point for an editor with no archetype
|
|
1022
|
+
# (any widget: the cue still knows where on the target it pressed).
|
|
1023
|
+
self.press_frac = None
|
|
1024
|
+
# The press as an OFFSET from the target's live top-left (px) - a
|
|
1025
|
+
# gates-only replay presses where the recording pressed on the
|
|
1026
|
+
# target, wherever the target sits now (a reorder moved the target,
|
|
1027
|
+
# a scroll fix shifted it). Overrides press_frac if set.
|
|
1028
|
+
self.press_offset = None
|
|
1029
|
+
# The CONTROL the cue pressed, as (width, height, frac_x, frac_y)
|
|
1030
|
+
# around the press point: where a re-pick may land. A header
|
|
1031
|
+
# button is a 30 px square on a 4000 px collection: a re-pick
|
|
1032
|
+
# anywhere else on the collection is not the button. None = the
|
|
1033
|
+
# target's visible rect.
|
|
1034
|
+
self.control = None
|
|
1035
|
+
|
|
1036
|
+
def __str__(self):
|
|
1037
|
+
if self.until is not None:
|
|
1038
|
+
return f"precondition {self.until[0]} '{self.until[1]}' for {format_path(parse(self.path))}"
|
|
1039
|
+
if self.gates_only:
|
|
1040
|
+
return f"open gates for {format_path(parse(self.path))}"
|
|
1041
|
+
if self.gesture == "header":
|
|
1042
|
+
return f"move_window({format_path(parse(self.path))} by {self.to!r})"
|
|
1043
|
+
return f"change_value({format_path(parse(self.path))} → {self.to!r})"
|
|
1044
|
+
|
|
1045
|
+
def _press_point_fn(self, ds, take=None):
|
|
1046
|
+
"""The press point this task aims at, live: the explicit point when
|
|
1047
|
+
one was given (a replay remap: the recorded press, re-anchored),
|
|
1048
|
+
else the header centre for a window gesture, else the demonstrated
|
|
1049
|
+
fraction of the leaf's rect."""
|
|
1050
|
+
if self.gesture == "header":
|
|
1051
|
+
# NEVER the cue's press offset here (see _header_point): a raise
|
|
1052
|
+
# recorded off a body click carries an offset deep in the
|
|
1053
|
+
# window, and a collapsed window is 32 px tall — the point read
|
|
1054
|
+
# as "clipped" on every round (Lukas 09-01, the stress test)
|
|
1055
|
+
return lambda: (self.press_point if self.press_point is not None
|
|
1056
|
+
else _header_point(ds, move_offset()))
|
|
1057
|
+
if self.press_offset is not None:
|
|
1058
|
+
def from_offset():
|
|
1059
|
+
if self.press_point is not None:
|
|
1060
|
+
return self.press_point
|
|
1061
|
+
left, top, _width, _height = _live_rect(ds)
|
|
1062
|
+
return (left + self.press_offset[0], top + self.press_offset[1])
|
|
1063
|
+
return from_offset
|
|
1064
|
+
if self.press_frac is not None:
|
|
1065
|
+
def from_frac():
|
|
1066
|
+
if self.press_point is not None:
|
|
1067
|
+
return self.press_point
|
|
1068
|
+
left, top, width, height = _live_rect(ds)
|
|
1069
|
+
return (left + self.press_frac[0] * width, top + self.press_frac[1] * height)
|
|
1070
|
+
return from_frac
|
|
1071
|
+
return lambda: _leaf_point(ds, take, self)
|
|
1072
|
+
|
|
1073
|
+
def _press_rect_fn(self, ds):
|
|
1074
|
+
"""The rect a re-pick may land in: the header strip for a window
|
|
1075
|
+
gesture, the leaf's visible rect otherwise (None = default)."""
|
|
1076
|
+
if self.gesture == "header":
|
|
1077
|
+
return lambda: _header_rect(ds)
|
|
1078
|
+
if self.control is not None:
|
|
1079
|
+
point_fn = self._press_point_fn(ds)
|
|
1080
|
+
|
|
1081
|
+
def control_rect():
|
|
1082
|
+
width, height, frac_x, frac_y = self.control
|
|
1083
|
+
x, y = point_fn()
|
|
1084
|
+
left, top = x - frac_x * width, y - frac_y * height
|
|
1085
|
+
return (left, top, left + width, top + height)
|
|
1086
|
+
return control_rect
|
|
1087
|
+
return None
|
|
1088
|
+
|
|
1089
|
+
def fail(self, message):
|
|
1090
|
+
self.error = self.error or str(message)
|
|
1091
|
+
self._done.set()
|
|
1092
|
+
|
|
1093
|
+
def finish(self):
|
|
1094
|
+
self._done.set()
|
|
1095
|
+
|
|
1096
|
+
def wait(self, timeout=None):
|
|
1097
|
+
self._done.wait(timeout)
|
|
1098
|
+
return self.error is None
|
|
1099
|
+
|
|
1100
|
+
# ----- generator body (engine steps it; yield = wait one frame) ----
|
|
1101
|
+
|
|
1102
|
+
def run(self):
|
|
1103
|
+
try:
|
|
1104
|
+
yield from self._run()
|
|
1105
|
+
except _PreconditionDone:
|
|
1106
|
+
self.result = "satisfied"
|
|
1107
|
+
except _Abort as abort:
|
|
1108
|
+
self.error = str(abort)
|
|
1109
|
+
|
|
1110
|
+
def _run(self):
|
|
1111
|
+
ds = yield from self._resolve_structural()
|
|
1112
|
+
if self._last_fix_frame is not None:
|
|
1113
|
+
yield from _wait_layout(ds, since_frame=self._last_fix_frame)
|
|
1114
|
+
if self.until is not None:
|
|
1115
|
+
# the chosen precondition was structural and is now open (or was
|
|
1116
|
+
# never unmet); the geometric ones still run but the ladder is
|
|
1117
|
+
# identical to the command's own - _apply_fixes stops the run
|
|
1118
|
+
# the moment the chosen one is satisfied
|
|
1119
|
+
take = take_for(_ARCHETYPE_BY_EDITOR.get(_editor_of(ds)))
|
|
1120
|
+
yield from self._satisfy(ds, self._press_point_fn(ds, take),
|
|
1121
|
+
rect=self._press_rect_fn(ds),
|
|
1122
|
+
header=self.gesture == "header")
|
|
1123
|
+
self.result = "satisfied"
|
|
1124
|
+
self._skip_verify = True
|
|
1125
|
+
return
|
|
1126
|
+
if self.gesture == "header":
|
|
1127
|
+
yield from self._run_move(ds)
|
|
1128
|
+
return
|
|
1129
|
+
if self.gates_only:
|
|
1130
|
+
# a recorded gesture about to play verbatim: make its target
|
|
1131
|
+
# hittable at the RECORDED press point (collapsed parents opened
|
|
1132
|
+
# by _resolve_structural; here the geometric ones - scrolled
|
|
1133
|
+
# out, covered), then hand back to the tape
|
|
1134
|
+
yield from self._satisfy(ds, self._press_point_fn(ds), rect=self._press_rect_fn(ds))
|
|
1135
|
+
self.result = ds
|
|
1136
|
+
return
|
|
1137
|
+
editor = _editor_of(ds)
|
|
1138
|
+
archetype = _ARCHETYPE_BY_EDITOR.get(editor)
|
|
1139
|
+
if archetype is None:
|
|
1140
|
+
raise _Abort(f"no archetype for editor {editor!r}")
|
|
1141
|
+
take = take_for(archetype)
|
|
1142
|
+
if take is None and archetype in _NEEDS_TAKE:
|
|
1143
|
+
raise _Abort(f"no {archetype} demonstration recorded — record one "
|
|
1144
|
+
f"({'drag any float field' if archetype == 'drag' else 'edit any text field'})")
|
|
1145
|
+
self._take = take
|
|
1146
|
+
# Geometric preconditions at the press point the executor will use
|
|
1147
|
+
# (a re-pick fix moves task.press_point; _leaf_point honours it).
|
|
1148
|
+
yield from self._satisfy(ds, lambda: _leaf_point(ds, take, self))
|
|
1149
|
+
self.trace_target = ds # engine trace reads its hover/active state
|
|
1150
|
+
runner = {"drag": _run_drag, "text": _run_text, "toggle": _run_toggle}[archetype]
|
|
1151
|
+
yield from runner(self, ds, take)
|
|
1152
|
+
if not self._skip_verify:
|
|
1153
|
+
yield from self._verify(ds)
|
|
1154
|
+
|
|
1155
|
+
def _run_move(self, window):
|
|
1156
|
+
"""The move archetype: grab the window by its header (the press a
|
|
1157
|
+
sub-goal like any other — a window over the header is raised /
|
|
1158
|
+
moved first, a re-pick stays inside the strip), drag by `to` =
|
|
1159
|
+
(dx, dy) at the recorded pace, verify by GEOMETRY: the window's
|
|
1160
|
+
live corner moved by the delta (within move_tolerance_px — a hard
|
|
1161
|
+
display limit or a collision that stopped it short is an honest
|
|
1162
|
+
abort naming how far it got)."""
|
|
1163
|
+
if move_offset() is None and not self.gates_only:
|
|
1164
|
+
raise _Abort("no window-move demonstration recorded — record one (drag "
|
|
1165
|
+
"any window by its header)")
|
|
1166
|
+
if self.gates_only:
|
|
1167
|
+
dx = dy = 0.0 # the tape does the moving; only the press matters here
|
|
1168
|
+
else:
|
|
1169
|
+
try:
|
|
1170
|
+
dx, dy = float(self.to[0]), float(self.to[1])
|
|
1171
|
+
except (TypeError, ValueError, IndexError):
|
|
1172
|
+
raise _Abort(f"move needs a (dx, dy) delta, got {self.to!r}")
|
|
1173
|
+
point_fn = self._press_point_fn(window)
|
|
1174
|
+
yield from self._wait_pacing("before_press")
|
|
1175
|
+
x, y = yield from self._satisfy(window, point_fn, rect=self._press_rect_fn(window),
|
|
1176
|
+
header=True)
|
|
1177
|
+
header = lambda: _header_point(window, move_offset())
|
|
1178
|
+
before = _live_rect(window)[:2]
|
|
1179
|
+
if self.gates_only:
|
|
1180
|
+
self.result = window # the tape presses and drags back
|
|
1181
|
+
return
|
|
1182
|
+
yield from _drag_by(_tracking(x, y, header), dx, dy, window)
|
|
1183
|
+
tolerance = Toggles.Orchestrator.move_tolerance_px
|
|
1184
|
+
moved = (0.0, 0.0)
|
|
1185
|
+
for _ in range(Toggles.Orchestrator.cue_wait_frames):
|
|
1186
|
+
after = _live_rect(window)[:2]
|
|
1187
|
+
moved = (after[0] - before[0], after[1] - before[1])
|
|
1188
|
+
if abs(moved[0] - dx) <= tolerance and abs(moved[1] - dy) <= tolerance:
|
|
1189
|
+
self.result = after
|
|
1190
|
+
self._skip_verify = True
|
|
1191
|
+
return
|
|
1192
|
+
yield
|
|
1193
|
+
raise _Abort(f"window '{display_name(window)}' moved by ({moved[0]:.0f}, {moved[1]:.0f}), "
|
|
1194
|
+
f"wanted ({dx:.0f}, {dy:.0f}) — stopped short (display edge / collision?)")
|
|
1195
|
+
|
|
1196
|
+
def _wait_pacing(self, key):
|
|
1197
|
+
yield from _wait_seconds(self.pacing.get(key, 0.0))
|
|
1198
|
+
|
|
1199
|
+
def _resolve_structural(self):
|
|
1200
|
+
"""Resolve the path and open the STRUCTURAL gates (closed windows,
|
|
1201
|
+
collapsed ancestors) until the leaf is live with geometry — the
|
|
1202
|
+
geometric preconditions (viewport, cover) need the press point,
|
|
1203
|
+
which needs the take, which needs the resolved editor."""
|
|
1204
|
+
# one gate opens per pass, so the bound is on STALLS - the same gate
|
|
1205
|
+
# unmet for consecutive passes (it re-closed, or the fix missed) -
|
|
1206
|
+
# never on the number of gates; three nested collections take three
|
|
1207
|
+
# passes, not three failures (09-01). The upper cap only stops a
|
|
1208
|
+
# pathological chain.
|
|
1209
|
+
stalls, last_gate = 0, None
|
|
1210
|
+
for _pass in range(max(64, Toggles.Orchestrator.gate_attempts * 4)):
|
|
1211
|
+
missing = None
|
|
1212
|
+
try:
|
|
1213
|
+
ds, _suffix = resolve_recorded(self.path, within=self.within,
|
|
1214
|
+
universe=self.universe)
|
|
1215
|
+
except NoMatch as error:
|
|
1216
|
+
ds, missing = None, error
|
|
1217
|
+
except Ambiguous as ambiguous:
|
|
1218
|
+
raise _Abort(str(ambiguous))
|
|
1219
|
+
if ds is not None and _reachable(ds):
|
|
1220
|
+
return ds
|
|
1221
|
+
frontier = ds
|
|
1222
|
+
if frontier is None:
|
|
1223
|
+
segments = parse(self.path)
|
|
1224
|
+
for length in range(len(segments) - 1, 0, -1):
|
|
1225
|
+
try:
|
|
1226
|
+
frontier = resolve(segments[:length], within=self.within,
|
|
1227
|
+
universe=self.universe)
|
|
1228
|
+
break
|
|
1229
|
+
except (NoMatch, Ambiguous):
|
|
1230
|
+
continue
|
|
1231
|
+
unmet = _first_unmet(frontier, _leaf_point(frontier, None)) if frontier is not None else None
|
|
1232
|
+
if unmet is None or unmet[0] not in ("closed", "collapsed"):
|
|
1233
|
+
if missing is not None:
|
|
1234
|
+
raise _Abort(str(missing)) # the NoMatch, default "unreachable"
|
|
1235
|
+
raise _Abort(f"{format_path(parse(self.path))} resolved but is not "
|
|
1236
|
+
f"hittable, and no closed gate found on its path")
|
|
1237
|
+
gate = (unmet[0], id(unmet[1]))
|
|
1238
|
+
stalls = stalls + 1 if gate == last_gate else 0
|
|
1239
|
+
last_gate = gate
|
|
1240
|
+
if stalls >= max(1, Toggles.Orchestrator.gate_attempts):
|
|
1241
|
+
raise _Abort(f"{display_name(unmet[1])} kept {'closing' if unmet[0] == 'closed' else 'collapsing'} "
|
|
1242
|
+
f"— gave up after {Toggles.Orchestrator.gate_attempts} attempts")
|
|
1243
|
+
yield from self._apply_fixes(unmet, frontier,
|
|
1244
|
+
lambda f=frontier: _leaf_point(f, None))
|
|
1245
|
+
yield # a layout frame before re-resolving
|
|
1246
|
+
raise _Abort("gates kept closing — gave up after "
|
|
1247
|
+
f"{max(64, Toggles.Orchestrator.gate_attempts * 4)} passes")
|
|
1248
|
+
|
|
1249
|
+
def _satisfy(self, ds, point_fn, depth=0, rect=None, header=False):
|
|
1250
|
+
"""Make `ds` hittable at the press point `point_fn()` returns — a
|
|
1251
|
+
CALLABLE, re-evaluated after every fix, because fixes move things
|
|
1252
|
+
(a scroll shifts the target, a move shifts a header); a re-pick
|
|
1253
|
+
replaces it with a constant inside `rect` (default the view's
|
|
1254
|
+
visible rect). Finds the first unmet precondition, runs its
|
|
1255
|
+
candidate fixes cheapest-first, re-checks after each. Returns the
|
|
1256
|
+
final point. Used for the target and, recursively, for a fix's own
|
|
1257
|
+
press point (the obscurer's header can itself be covered — a
|
|
1258
|
+
sub-goal under the same rules, depth-limited)."""
|
|
1259
|
+
if depth > Toggles.Orchestrator.fix_depth:
|
|
1260
|
+
raise _Abort("precondition fixes nested too deep")
|
|
1261
|
+
saved_depth, self._depth = self._depth, depth
|
|
1262
|
+
since = len(self.attempts) # this scope's own attempts (the message lists just these)
|
|
1263
|
+
try:
|
|
1264
|
+
for _round in range(max(1, Toggles.Orchestrator.gate_attempts) * 2):
|
|
1265
|
+
unmet = _first_unmet(ds, point_fn(), header=header)
|
|
1266
|
+
if unmet is None:
|
|
1267
|
+
break
|
|
1268
|
+
point_fn = yield from self._apply_fixes(unmet, ds, point_fn, rect,
|
|
1269
|
+
since=since, header=header)
|
|
1270
|
+
yield
|
|
1271
|
+
point = point_fn()
|
|
1272
|
+
unmet = _first_unmet(ds, point, header=header)
|
|
1273
|
+
if unmet is not None:
|
|
1274
|
+
raise _Abort(self._unmet_message(unmet, ds, since=since))
|
|
1275
|
+
if depth == 0:
|
|
1276
|
+
self.press_point = point
|
|
1277
|
+
return point
|
|
1278
|
+
finally:
|
|
1279
|
+
self._depth = saved_depth
|
|
1280
|
+
|
|
1281
|
+
def _apply_fixes(self, unmet, ds, point_fn, rect=None, since=0, header=False):
|
|
1282
|
+
"""Run the candidates for one unmet precondition until the
|
|
1283
|
+
precondition changes (fixed, or a different one surfaced). Returns
|
|
1284
|
+
the press point callable (a re-pick replaces it). `since` = where
|
|
1285
|
+
this scope's attempts start in the flat log."""
|
|
1286
|
+
self._unmet = unmet
|
|
1287
|
+
for cost, label, make in _candidates(self, unmet, ds, point_fn, rect):
|
|
1288
|
+
if label in self._active_fixes:
|
|
1289
|
+
continue # this fix's own sub-goal: not a candidate
|
|
1290
|
+
self._picked = None
|
|
1291
|
+
self._active_fixes.append(label)
|
|
1292
|
+
try:
|
|
1293
|
+
yield from make()
|
|
1294
|
+
except _Abort as abort:
|
|
1295
|
+
# a nested failure's own attempts are already in the flat
|
|
1296
|
+
# log; keep its HEAD here, never its transcript (re-embedding
|
|
1297
|
+
# it at every level blew the report past a megabyte, 09-01)
|
|
1298
|
+
self.attempts.append(f"{label} ({_abort_head(abort)})")
|
|
1299
|
+
continue
|
|
1300
|
+
finally:
|
|
1301
|
+
self._active_fixes.remove(label)
|
|
1302
|
+
self._unmet = unmet # a nested fix overwrote it
|
|
1303
|
+
self._last_fix_frame = Melty.frame_count
|
|
1304
|
+
if self._picked is not None:
|
|
1305
|
+
point_fn = (lambda p=self._picked: p)
|
|
1306
|
+
else:
|
|
1307
|
+
yield from _wait_layout(ds) # the fix moved things: re-checking geometry
|
|
1308
|
+
after = _first_unmet(ds, point_fn(), header=header)
|
|
1309
|
+
if after is None or after != unmet:
|
|
1310
|
+
self.attempts.append(f"{label} (ok, cost {cost})")
|
|
1311
|
+
if self.until is not None and precondition_key(unmet) == self.until:
|
|
1312
|
+
raise _PreconditionDone()
|
|
1313
|
+
return point_fn
|
|
1314
|
+
self.attempts.append(f"{label} (still {unmet[0]})")
|
|
1315
|
+
raise _Abort(self._unmet_message(unmet, ds, since=since))
|
|
1316
|
+
|
|
1317
|
+
def _re_pick(self, ds, rect=None):
|
|
1318
|
+
"""The zero-cost fix: another press point inside the visible part
|
|
1319
|
+
of the target (or `rect`) that is NOT covered — a partially
|
|
1320
|
+
obscured target needs no window touched."""
|
|
1321
|
+
if callable(rect):
|
|
1322
|
+
rect = rect() # a live rect follows the fixes that moved things
|
|
1323
|
+
left, top, right, bottom = rect if rect is not None else _visible_rect(ds)
|
|
1324
|
+
window = _window_of(ds)
|
|
1325
|
+
for fx, fy in ((0.5, 0.5), (0.2, 0.5), (0.8, 0.5), (0.5, 0.2), (0.5, 0.8),
|
|
1326
|
+
(0.1, 0.1), (0.9, 0.1), (0.1, 0.9), (0.9, 0.9)):
|
|
1327
|
+
x = left + fx * (right - left)
|
|
1328
|
+
y = top + fy * (bottom - top)
|
|
1329
|
+
front = _front_window_at(x, y)
|
|
1330
|
+
if front is None or front is window:
|
|
1331
|
+
self._picked = (x, y)
|
|
1332
|
+
return
|
|
1333
|
+
yield
|
|
1334
|
+
raise _Abort("no uncovered point on the target")
|
|
1335
|
+
|
|
1336
|
+
def _unmet_message(self, unmet, ds, since=0):
|
|
1337
|
+
kind, node = unmet
|
|
1338
|
+
what = {"closed": f"window '{display_name(node)}' is closed",
|
|
1339
|
+
"collapsed": f"'{display_name(node)}' is collapsed",
|
|
1340
|
+
"outside": f"'{display_name(ds)}' is outside its scroll viewport",
|
|
1341
|
+
"clipped": f"'{display_name(ds)}' is clipped at its press point",
|
|
1342
|
+
"obscured": f"'{display_name(ds)}' is covered by '{display_name(node)}'"}[kind]
|
|
1343
|
+
attempts = self.attempts[since:]
|
|
1344
|
+
tried = "; ".join(attempts) if attempts else "nothing applicable"
|
|
1345
|
+
return f"{what} — tried: {tried}"
|
|
1346
|
+
|
|
1347
|
+
def _verify(self, ds):
|
|
1348
|
+
"""The parameterized cue: a Change on the TARGET whose new value is
|
|
1349
|
+
`to` — expectation from the caller, mechanism from the take."""
|
|
1350
|
+
change = None
|
|
1351
|
+
for _ in range(Toggles.Orchestrator.cue_wait_frames):
|
|
1352
|
+
change = _live_change_on(ds, self.start_frame)
|
|
1353
|
+
if change is not None and _value_matches(change.new, self.to, self._verify_eps):
|
|
1354
|
+
self.result = change.new
|
|
1355
|
+
return
|
|
1356
|
+
yield
|
|
1357
|
+
reached = change.new if change is not None else "unchanged"
|
|
1358
|
+
raise _Abort(f"value is {reached!r}, wanted {self.to!r}")
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _abort_head(abort):
|
|
1362
|
+
"""A precondition failure's one-line cause, without its attempts."""
|
|
1363
|
+
return str(abort).split(" — tried:")[0]
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _value_matches(value, target, eps):
|
|
1367
|
+
if isinstance(target, bool) or isinstance(target, str):
|
|
1368
|
+
return value == target
|
|
1369
|
+
if isinstance(target, (int, float)) and isinstance(value, (int, float)):
|
|
1370
|
+
return abs(float(value) - float(target)) <= max(eps, 1e-9)
|
|
1371
|
+
return value == target
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
# ── preconditions as data (the orchestrator window lists and runs them) ──
|
|
1375
|
+
|
|
1376
|
+
def precondition_key(unmet):
|
|
1377
|
+
"""The identity of an unmet precondition across frames: its kind and
|
|
1378
|
+
the display name of the node the fix acts on — what the window's run
|
|
1379
|
+
chip hands back as ValueTask.until."""
|
|
1380
|
+
kind, node = unmet
|
|
1381
|
+
return (kind, display_name(node))
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def list_preconditions(path, within=None, universe=None, gesture="leaf", press_frac=None):
|
|
1385
|
+
"""Every precondition currently unmet on the way to `path`'s target,
|
|
1386
|
+
outermost first, as rows the orchestrator window renders:
|
|
1387
|
+
{"key", "kind", "node", "label", "fixes": [(cost, label), …]} — the
|
|
1388
|
+
same walk the solver takes (structural gates on the resolved chain or
|
|
1389
|
+
on the deepest resolvable frontier, then the first geometric one at
|
|
1390
|
+
the demonstrated press point), without running anything. Empty when
|
|
1391
|
+
the target is hittable now; a NoMatch / Ambiguous path yields one
|
|
1392
|
+
"unresolved" row."""
|
|
1393
|
+
task = ValueTask(path, None, within=within, universe=universe)
|
|
1394
|
+
task.gesture = gesture
|
|
1395
|
+
task.press_frac = press_frac
|
|
1396
|
+
try:
|
|
1397
|
+
ds, _suffix = resolve_recorded(path, within=within, universe=universe)
|
|
1398
|
+
except (NoMatch, Ambiguous) as error:
|
|
1399
|
+
ds = None
|
|
1400
|
+
frontier = None
|
|
1401
|
+
for length in range(len(parse(path)) - 1, 0, -1):
|
|
1402
|
+
try:
|
|
1403
|
+
frontier = resolve(parse(path)[:length], within=within, universe=universe)
|
|
1404
|
+
break
|
|
1405
|
+
except (NoMatch, Ambiguous):
|
|
1406
|
+
continue
|
|
1407
|
+
if frontier is None:
|
|
1408
|
+
return [{"key": ("unresolved", format_path(parse(path))), "kind": "unresolved",
|
|
1409
|
+
"node": format_path(parse(path)), "label": str(error), "fixes": []}]
|
|
1410
|
+
else:
|
|
1411
|
+
frontier = ds
|
|
1412
|
+
rows = []
|
|
1413
|
+
nodes = [frontier] + ancestor_chain(frontier)
|
|
1414
|
+
for node in reversed(nodes): # outermost first, like _first_unmet
|
|
1415
|
+
if getattr(node, "closed", False):
|
|
1416
|
+
rows.append(("closed", node))
|
|
1417
|
+
elif node is not frontier and getattr(node, "expanded", True) is False:
|
|
1418
|
+
rows.append(("collapsed", node))
|
|
1419
|
+
if ds is not None and not rows and _reachable(ds):
|
|
1420
|
+
take = take_for(_ARCHETYPE_BY_EDITOR.get(_editor_of(ds)))
|
|
1421
|
+
point = task._press_point_fn(ds, take)()
|
|
1422
|
+
unmet = _first_unmet(ds, point, header=gesture == "header")
|
|
1423
|
+
if unmet is not None:
|
|
1424
|
+
rows.append(unmet)
|
|
1425
|
+
elif ds is None and not rows:
|
|
1426
|
+
rows.append(("unresolved", frontier))
|
|
1427
|
+
out = []
|
|
1428
|
+
for unmet in rows:
|
|
1429
|
+
kind, node = unmet
|
|
1430
|
+
fixes = []
|
|
1431
|
+
if kind == "unresolved":
|
|
1432
|
+
out.append({"key": precondition_key(unmet), "kind": kind, "node": display_name(node),
|
|
1433
|
+
"label": f"{format_path(parse(path))} does not resolve below "
|
|
1434
|
+
f"'{display_name(node)}' (open / expand it first)",
|
|
1435
|
+
"fixes": []})
|
|
1436
|
+
continue
|
|
1437
|
+
if kind != "unresolved":
|
|
1438
|
+
try:
|
|
1439
|
+
subject = ds if ds is not None else node
|
|
1440
|
+
point_fn = task._press_point_fn(subject)
|
|
1441
|
+
fixes = [(cost, label) for cost, label, _make
|
|
1442
|
+
in _candidates(task, unmet, subject, point_fn,
|
|
1443
|
+
task._press_rect_fn(subject))]
|
|
1444
|
+
except _Abort as abort:
|
|
1445
|
+
fixes = [(99, str(abort))]
|
|
1446
|
+
out.append({"key": precondition_key(unmet), "kind": kind,
|
|
1447
|
+
"node": display_name(node),
|
|
1448
|
+
"label": task._unmet_message(unmet, ds if ds is not None else node)
|
|
1449
|
+
.split(" — tried:")[0],
|
|
1450
|
+
"fixes": fixes})
|
|
1451
|
+
return out
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def describe_target(path, within=None, universe=None, gesture="leaf", press_frac=None):
|
|
1455
|
+
"""What the solver sees for `path` right now — the facts every
|
|
1456
|
+
precondition verdict is computed from, for the window's hittable row
|
|
1457
|
+
and the failure report: the resolved leaf (name, editor, rect, visible
|
|
1458
|
+
rect, window), the press point it would use, and the front window at
|
|
1459
|
+
that point. Never raises."""
|
|
1460
|
+
out = {"path": format_path(parse(path))}
|
|
1461
|
+
try:
|
|
1462
|
+
ds, _suffix = resolve_recorded(path, within=within, universe=universe)
|
|
1463
|
+
except (NoMatch, Ambiguous) as error:
|
|
1464
|
+
out["resolved"] = None
|
|
1465
|
+
out["error"] = str(error)
|
|
1466
|
+
return out
|
|
1467
|
+
out["resolved"] = full_name(ds)
|
|
1468
|
+
out["editor"] = _editor_of(ds)
|
|
1469
|
+
out["rect"] = tuple(round(v, 1) for v in _live_rect(ds))
|
|
1470
|
+
out["visible"] = tuple(round(v, 1) for v in _visible_rect(ds))
|
|
1471
|
+
out["reachable"] = _reachable(ds)
|
|
1472
|
+
window = _window_of(ds)
|
|
1473
|
+
out["window"] = display_name(window) if window is not None else None
|
|
1474
|
+
out["chain"] = [display_name(w) for w in _window_chain(ds)]
|
|
1475
|
+
take = take_for(_ARCHETYPE_BY_EDITOR.get(_editor_of(ds)))
|
|
1476
|
+
out["take"] = getattr(take, "name", None) if take is not None else None
|
|
1477
|
+
probe = ValueTask(path, None, within=within, universe=universe)
|
|
1478
|
+
probe.gesture = gesture
|
|
1479
|
+
probe.press_frac = press_frac
|
|
1480
|
+
point = probe._press_point_fn(ds, take)()
|
|
1481
|
+
out["point"] = (round(point[0], 1), round(point[1], 1))
|
|
1482
|
+
front = _front_window_at(point[0], point[1])
|
|
1483
|
+
out["front"] = display_name(front) if front is not None else None
|
|
1484
|
+
unmet = _first_unmet(ds, point, header=gesture == "header")
|
|
1485
|
+
out["unmet"] = (unmet[0], display_name(unmet[1])) if unmet is not None else None
|
|
1486
|
+
return out
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def precondition_task(path, key, orchestration=None, within=None, universe=None,
|
|
1490
|
+
gesture="leaf", press_frac=None):
|
|
1491
|
+
"""A ValueTask that satisfies preconditions up to and including `key`
|
|
1492
|
+
(from list_preconditions) and stops — the window's run chip. Submit it
|
|
1493
|
+
to the Orchestrator like any task."""
|
|
1494
|
+
task = ValueTask(path, None, within=within, universe=universe)
|
|
1495
|
+
task.until = tuple(key)
|
|
1496
|
+
task.orchestration = orchestration
|
|
1497
|
+
task.gesture = gesture
|
|
1498
|
+
task.press_frac = press_frac
|
|
1499
|
+
return task
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def move_window(path, delta, within=None, universe=None):
|
|
1503
|
+
"""Move the window `path` names by `delta` = (dx, dy) through a real
|
|
1504
|
+
header drag — the move archetype (see ValueTask._run_move). The header
|
|
1505
|
+
press is a precondition sub-goal like any leaf press."""
|
|
1506
|
+
task = ValueTask(path, (float(delta[0]), float(delta[1])), within=within, universe=universe)
|
|
1507
|
+
task.gesture = "header"
|
|
1508
|
+
return Orchestrator.submit(task)
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
# ── archetype executors ───────────────────────────────────────────────────
|
|
1512
|
+
|
|
1513
|
+
def _read_value(ds, since_frame):
|
|
1514
|
+
"""The target's live numeric value: the coalescing Change's `new` when
|
|
1515
|
+
one has been recorded this run, else the draw_state's own held value
|
|
1516
|
+
(`_raw_input_value` — updated the frame the wrapper runs, so it sees an
|
|
1517
|
+
edit the undo record missed or lagged). None when neither is numeric."""
|
|
1518
|
+
change = _live_change_on(ds, since_frame)
|
|
1519
|
+
if change is not None and isinstance(change.new, (int, float)):
|
|
1520
|
+
return float(change.new)
|
|
1521
|
+
value = _current_value(ds)
|
|
1522
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
1523
|
+
return float(value)
|
|
1524
|
+
return None
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _run_drag(task, ds, take):
|
|
1528
|
+
"""Press at the demonstrated fraction, confirm the widget took the press
|
|
1529
|
+
(imgui active), probe to measure the LIVE gain, secant-servo to the
|
|
1530
|
+
target, release. The recorded gain is never used — the probe replaces
|
|
1531
|
+
all calibration. The sensor is the live value (_read_value): undo
|
|
1532
|
+
Change first, the draw_state's held value as the fallback."""
|
|
1533
|
+
probe_px = Toggles.Orchestrator.servo_probe_px
|
|
1534
|
+
max_step = Toggles.Orchestrator.servo_max_step_px
|
|
1535
|
+
point = lambda: _leaf_point(ds, take, task) # live: the leaf may shift while we travel
|
|
1536
|
+
since = task.start_frame
|
|
1537
|
+
yield from _settle_at(point, ds)
|
|
1538
|
+
yield from _wait_seconds(task.pacing.get("before_press", 0.0))
|
|
1539
|
+
x, y = point()
|
|
1540
|
+
baseline = _read_value(ds, since)
|
|
1541
|
+
_press(x, y, ds)
|
|
1542
|
+
yield
|
|
1543
|
+
# the widget must be ACTIVE before the probe moves - a press whose frame
|
|
1544
|
+
# served the tile from cache never reaches imgui (active flickers off);
|
|
1545
|
+
# keep the tile live while waiting (a raise on the press frame
|
|
1546
|
+
# reshuffles tiles, and imgui drops an active item it misses that frame)
|
|
1547
|
+
for _ in range(Toggles.Orchestrator.settle_hover_pumps):
|
|
1548
|
+
if getattr(ds, "_imgui_is_active", False):
|
|
1549
|
+
break
|
|
1550
|
+
_render_live(ds)
|
|
1551
|
+
yield
|
|
1552
|
+
else:
|
|
1553
|
+
_release(x, y, ds)
|
|
1554
|
+
yield
|
|
1555
|
+
raise _Abort("press did not activate the widget (tile not live at the press?)")
|
|
1556
|
+
yield from _wait_seconds(task.pacing.get("before_drag", 0.0))
|
|
1557
|
+
if baseline is None:
|
|
1558
|
+
_release(x, y, ds)
|
|
1559
|
+
raise _Abort("target holds no numeric value to drag")
|
|
1560
|
+
# Probe TOWARD the target: a value pinned at a min/max limit ignores a
|
|
1561
|
+
# push further into the clamp (alpha at its 99.264 ceiling ate a +8 px
|
|
1562
|
+
# probe), while a push toward the target always has room.
|
|
1563
|
+
direction = -1.0 if float(task.to) < baseline else 1.0
|
|
1564
|
+
# The probe GROWS until the field responds: an integer drag accumulates
|
|
1565
|
+
# fractional units (rank at 16 px per unit) and ignores a short probe, so
|
|
1566
|
+
# 8 px is only the first try - double up to servo_max_step_px.
|
|
1567
|
+
origin_x = x
|
|
1568
|
+
step = probe_px * direction
|
|
1569
|
+
current = baseline
|
|
1570
|
+
while abs(step) <= max_step:
|
|
1571
|
+
x = origin_x + step
|
|
1572
|
+
_move(x, y)
|
|
1573
|
+
yield
|
|
1574
|
+
yield # a frame for the value to settle
|
|
1575
|
+
current = _read_value(ds, since)
|
|
1576
|
+
if current is not None and current != baseline:
|
|
1577
|
+
break
|
|
1578
|
+
step *= 2.0
|
|
1579
|
+
else:
|
|
1580
|
+
step = origin_x - x # nothing moved, measure the reverse from here
|
|
1581
|
+
if current is None or current == baseline:
|
|
1582
|
+
# nothing toward the target: the other way separates pinned-at-the-limit
|
|
1583
|
+
# (the target's beyond the range) from not-draggable-at-all
|
|
1584
|
+
x = origin_x - (x - origin_x)
|
|
1585
|
+
_move(x, y)
|
|
1586
|
+
yield
|
|
1587
|
+
yield
|
|
1588
|
+
reverse = _read_value(ds, since)
|
|
1589
|
+
_release(x, y, ds)
|
|
1590
|
+
yield
|
|
1591
|
+
if reverse is not None and reverse != baseline:
|
|
1592
|
+
raise _Abort(f"value {baseline:g} is at its limit in the direction of "
|
|
1593
|
+
f"{float(task.to):g} — target outside the field's range")
|
|
1594
|
+
raise _Abort(f"probe drag changed nothing (value {baseline!r}, widget active="
|
|
1595
|
+
f"{bool(getattr(ds, '_imgui_is_active', False))}) — not a draggable value?")
|
|
1596
|
+
gain = (current - baseline) / step
|
|
1597
|
+
# convergence tolerance within half a pixel's worth of value (the widget's own
|
|
1598
|
+
# tolerance), unless the caller pinned one
|
|
1599
|
+
eps = task.eps if task.eps is not None else max(abs(gain) * 0.5, 1e-9)
|
|
1600
|
+
task._verify_eps = eps
|
|
1601
|
+
# ---- easing travel: spread the estimated distance over the RECORDED
|
|
1602
|
+
# drag_duration on a smooth curve (a hand, not a lurch), refining the
|
|
1603
|
+
# distance estimate from the value as it comes in; the step servo below
|
|
1604
|
+
# then only closes the last quantum ----
|
|
1605
|
+
duration = task.pacing.get("drag_duration", 0.0)
|
|
1606
|
+
if duration > 0.0 and gain != 0.0:
|
|
1607
|
+
travel = (float(task.to) - baseline) / gain # px from the press, first estimate
|
|
1608
|
+
start_x = x # the curve begins where the probe left off
|
|
1609
|
+
started = time.monotonic()
|
|
1610
|
+
heading = 1.0 if travel >= 0 else -1.0
|
|
1611
|
+
while True:
|
|
1612
|
+
fraction = min(1.0, (time.monotonic() - started) / duration)
|
|
1613
|
+
eased = fraction * fraction * (3.0 - 2.0 * fraction)
|
|
1614
|
+
goal = start_x + (origin_x + travel - start_x) * eased
|
|
1615
|
+
# MONOTONIC: the endpoint estimate may shrink as the gain refines,
|
|
1616
|
+
# but the cursor never reverses mid-drag (a quantized field's
|
|
1617
|
+
# noisy secant had it fighting itself)
|
|
1618
|
+
if (goal - x) * heading > 0:
|
|
1619
|
+
x = goal
|
|
1620
|
+
_move(x, y)
|
|
1621
|
+
yield
|
|
1622
|
+
fresh = _read_value(ds, since)
|
|
1623
|
+
# whole-drag secant: in once the cursor is a probe-length past
|
|
1624
|
+
# the press (nearer, the ratio is noisy; one tick at the origin
|
|
1625
|
+
# once divided by 1e-9 and pinned the travel at zero); the
|
|
1626
|
+
# endpoint moves at most a quarter of the way per tick (damped)
|
|
1627
|
+
if (fresh is not None and fresh != baseline
|
|
1628
|
+
and abs(x - origin_x) >= probe_px):
|
|
1629
|
+
measured = (fresh - baseline) / (x - origin_x)
|
|
1630
|
+
current = fresh
|
|
1631
|
+
if measured != 0.0 and (measured > 0) == (gain > 0):
|
|
1632
|
+
gain = gain + (measured - gain) * 0.25
|
|
1633
|
+
travel = (float(task.to) - baseline) / gain
|
|
1634
|
+
if fraction >= 1.0:
|
|
1635
|
+
break
|
|
1636
|
+
yield
|
|
1637
|
+
yield
|
|
1638
|
+
fresh = _read_value(ds, since)
|
|
1639
|
+
if fresh is not None:
|
|
1640
|
+
current = fresh
|
|
1641
|
+
stalls = 0
|
|
1642
|
+
damping = 1.0
|
|
1643
|
+
last_residual = None
|
|
1644
|
+
for _ in range(Toggles.Orchestrator.servo_max_steps):
|
|
1645
|
+
residual = float(task.to) - current
|
|
1646
|
+
if abs(residual) <= eps:
|
|
1647
|
+
break
|
|
1648
|
+
dx = max(-max_step, min(max_step, residual / gain))
|
|
1649
|
+
# damped: overshooting flips the residual's sign - halve the stride
|
|
1650
|
+
# from then on so the close never ping-pongs across the target
|
|
1651
|
+
if last_residual is not None and (residual > 0) != (last_residual > 0):
|
|
1652
|
+
damping *= 0.5
|
|
1653
|
+
last_residual = residual
|
|
1654
|
+
dx *= damping
|
|
1655
|
+
x += dx
|
|
1656
|
+
_move(x, y)
|
|
1657
|
+
# A move lands imgui at the NEXT frame's io stamp and the Change
|
|
1658
|
+
# lands after that frame's render: read two pumps later (the probe
|
|
1659
|
+
# already did; the loop read one pump early and called every step a
|
|
1660
|
+
# stall).
|
|
1661
|
+
yield
|
|
1662
|
+
yield
|
|
1663
|
+
fresh = _read_value(ds, since)
|
|
1664
|
+
if fresh is None or fresh == current:
|
|
1665
|
+
stalls += 1
|
|
1666
|
+
if stalls >= 3: # three settled reads, no motion
|
|
1667
|
+
_release(x, y, ds)
|
|
1668
|
+
yield
|
|
1669
|
+
raise _Abort(f"stalled at {current:g} heading for {float(task.to):g} "
|
|
1670
|
+
f"(min/max clamp?)")
|
|
1671
|
+
yield # give a next frame one more chance
|
|
1672
|
+
continue
|
|
1673
|
+
stalls = 0
|
|
1674
|
+
gain = (fresh - current) / dx # re-measure every step
|
|
1675
|
+
current = fresh
|
|
1676
|
+
else:
|
|
1677
|
+
_release(x, y, ds)
|
|
1678
|
+
yield
|
|
1679
|
+
raise _Abort(f"did not converge (at {current:g}, wanted {float(task.to):g})")
|
|
1680
|
+
_release(x, y, ds)
|
|
1681
|
+
yield
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _run_text(task, ds, take):
|
|
1685
|
+
"""Click to focus, synthesized clear, payload typed from `to` in the
|
|
1686
|
+
event shape a real keystroke produces, one clear-and-retype retry."""
|
|
1687
|
+
text = str(task.to)
|
|
1688
|
+
point = lambda: _leaf_point(ds, take, task)
|
|
1689
|
+
since = task.start_frame
|
|
1690
|
+
yield from _settle_at(point, ds)
|
|
1691
|
+
x, y = point()
|
|
1692
|
+
_press(x, y, ds)
|
|
1693
|
+
yield
|
|
1694
|
+
_release(x, y, ds)
|
|
1695
|
+
yield
|
|
1696
|
+
for attempt in range(2):
|
|
1697
|
+
# ---- clear: select-all + delete; End+Backspace fallback ----
|
|
1698
|
+
_key(glfw.KEY_A, glfw.MOD_CONTROL)
|
|
1699
|
+
yield
|
|
1700
|
+
_key(glfw.KEY_DELETE)
|
|
1701
|
+
yield
|
|
1702
|
+
yield
|
|
1703
|
+
remaining = _current_value(ds)
|
|
1704
|
+
if isinstance(remaining, str) and remaining:
|
|
1705
|
+
_key(glfw.KEY_END)
|
|
1706
|
+
yield
|
|
1707
|
+
for _ in range(len(remaining)):
|
|
1708
|
+
_key(glfw.KEY_BACKSPACE)
|
|
1709
|
+
yield
|
|
1710
|
+
yield
|
|
1711
|
+
# ---- payload: key + char per character, like a real keystroke ----
|
|
1712
|
+
for index, character in enumerate(text):
|
|
1713
|
+
keycode = _keycode_for(character)
|
|
1714
|
+
if keycode is not None:
|
|
1715
|
+
_key(keycode, glfw.MOD_SHIFT if character.isupper() else 0)
|
|
1716
|
+
_char(ord(character))
|
|
1717
|
+
if index % 4 == 3:
|
|
1718
|
+
yield # pace: 4 chars a frame
|
|
1719
|
+
yield
|
|
1720
|
+
yield
|
|
1721
|
+
change = _live_change_on(ds, since)
|
|
1722
|
+
if change is not None and change.new == text:
|
|
1723
|
+
return # _verify confirms exactly
|
|
1724
|
+
if attempt == 0:
|
|
1725
|
+
continue # once more: clear + retype
|
|
1726
|
+
# fall through - _verify delivers an honest comparison with what we reached
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _keycode_for(character):
|
|
1730
|
+
"""Best-effort glfw keycode for an ASCII character (letters, digits,
|
|
1731
|
+
space). Anything else rides the char event alone."""
|
|
1732
|
+
if character.isascii() and character.isalpha():
|
|
1733
|
+
return ord(character.upper())
|
|
1734
|
+
if character.isdigit():
|
|
1735
|
+
return ord(character)
|
|
1736
|
+
if character == " ":
|
|
1737
|
+
return glfw.KEY_SPACE
|
|
1738
|
+
return None
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _run_toggle(task, ds, take):
|
|
1742
|
+
if isinstance(task.to, bool) and _current_value(ds) == task.to:
|
|
1743
|
+
task.result = task.to # nothing to do, nothing to verify
|
|
1744
|
+
task._skip_verify = True
|
|
1745
|
+
return
|
|
1746
|
+
point = lambda: _leaf_point(ds, take, task)
|
|
1747
|
+
yield from _settle_at(point, ds)
|
|
1748
|
+
x, y = point()
|
|
1749
|
+
_press(x, y, ds)
|
|
1750
|
+
yield
|
|
1751
|
+
_release(x, y, ds)
|
|
1752
|
+
yield
|