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