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,1101 @@
1
+ """
2
+ Low-latency input event handler.
3
+
4
+ No callbacks - single method returns {view_id: [events]}.
5
+ Device-agnostic actions auto-parsed from subscription names.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from dataclasses import dataclass
10
+ from typing import Any, Optional
11
+ import time
12
+
13
+ import meltygui_imgui as imgui
14
+
15
+
16
+ class EventAction:
17
+ DOWN = "down"
18
+ UP = "up"
19
+ DRAGGED = "dragged"
20
+ DRAG_RELEASED = "drag_released"
21
+ # The drag variants refer on double-click: the SECOND press of a double-click,
22
+ # held and dragged. Fires continuously (like DRAGGED) then once on release.
23
+ DOUBLE_DRAGGED = "double_dragged"
24
+ DOUBLE_DRAG_RELEASED = "double_drag_released"
25
+ CLICKED = "clicked"
26
+ DOUBLE_CLICKED = "double_clicked"
27
+ CHANGED = "changed"
28
+ MOVED = "moved"
29
+ HOVERED = "hovered" # Continuous - fires every frame while hovered
30
+ HOVER_ENTER = "hover_enter" # Once - when hover starts
31
+ HOVER_EXIT = "hover_exit" # Once - when hover ends
32
+ HELD = "held" # Continuous - fires every frame while down but within drag threshold
33
+
34
+
35
+ ACTION_ALIASES = {
36
+ "pressed": EventAction.DOWN,
37
+ "released": EventAction.UP,
38
+ "drag": EventAction.DRAGGED,
39
+ "drag_release": EventAction.DRAG_RELEASED,
40
+ "double_drag": EventAction.DOUBLE_DRAGGED,
41
+ "double_drag_release": EventAction.DOUBLE_DRAG_RELEASED,
42
+ "click": EventAction.CLICKED,
43
+ "double_click": EventAction.DOUBLE_CLICKED,
44
+ # Continuous hover
45
+ "hover": EventAction.HOVERED,
46
+ "on_hover": EventAction.HOVERED,
47
+ # Enter/exit
48
+ "on_hover_enter": EventAction.HOVER_ENTER,
49
+ "on_hover_exit": EventAction.HOVER_EXIT,
50
+ "unhovered": EventAction.HOVER_EXIT,
51
+ "unhover": EventAction.HOVER_EXIT,
52
+ # Held (down within drag threshold)
53
+ "hold": EventAction.HELD,
54
+ "holding": EventAction.HELD,
55
+ "on_hold": EventAction.HELD,
56
+ }
57
+
58
+ ALL_ACTIONS = frozenset({
59
+ EventAction.DOWN, EventAction.UP, EventAction.DRAGGED, EventAction.DRAG_RELEASED,
60
+ EventAction.DOUBLE_DRAGGED, EventAction.DOUBLE_DRAG_RELEASED, EventAction.CLICKED,
61
+ EventAction.DOUBLE_CLICKED, EventAction.CHANGED, EventAction.MOVED,
62
+ EventAction.HOVERED, EventAction.HOVER_ENTER, EventAction.HOVER_EXIT, EventAction.HELD,
63
+ *ACTION_ALIASES.keys()
64
+ })
65
+ _SORTED_ACTIONS = tuple(sorted(ALL_ACTIONS, key=len, reverse=True))
66
+
67
+ # A "double" word anywhere in a subscription name promotes the base gesture to
68
+ # its double-press variant, so "double_right_mouse_drag" and the suffix form
69
+ # "right_mouse_double_drag" both canonicalise to DOUBLE_DRAGGED on right_mouse.
70
+ # Parsed like the inverted/non_blocking flags (see parse_event_name).
71
+ _DOUBLE_PROMOTE = {
72
+ EventAction.DRAGGED: EventAction.DOUBLE_DRAGGED,
73
+ EventAction.DRAG_RELEASED: EventAction.DOUBLE_DRAG_RELEASED,
74
+ EventAction.CLICKED: EventAction.DOUBLE_CLICKED,
75
+ }
76
+
77
+ # Max gap between the two clicks' RELEASES. Must clear human double-click speed
78
+ # (~150-300ms between releases; OS defaults ~500ms, imgui uses 300ms) or doubles
79
+ # never register - at 0.1 left_mouse_double_clicked (voxel params panel) never
80
+ # fired and right double-clicks fell over as two sloppy singles. This value
81
+ # ALSO sets how long a deferred single click waits before firing (only on inputs
82
+ # with a double subscriber - see process_frame), so it's the single↔double
83
+ # trade-off here: lower = snappier single click but flakier double detection.
84
+ DOUBLE_CLICK_WINDOW = 0.25
85
+ CLICK_MAX_DISTANCE = 5.0
86
+ DRAG_THRESHOLD = 2.0 # Minimum distance before drag activates
87
+
88
+
89
+ @dataclass(slots=True)
90
+ class InputEvent:
91
+ input_id: str
92
+ action: str
93
+ tile_id: str = None
94
+ x: float = 0.0
95
+ y: float = 0.0
96
+ dx: float = 0.0
97
+ dy: float = 0.0
98
+ value: float = 0.0
99
+ timestamp: float = 0.0
100
+ modifiers: int = 0
101
+ total_dx: float = 0.0
102
+ total_dy: float = 0.0
103
+ # Multi-axis payload (feed_axes): the 6-DOF reading of a 3D mouse as
104
+ # (tx, ty, tz, rx, ry, rz), each axis the deflection INTEGRATED over the
105
+ # frame in full-deflection-terms (see events/space_mouse.py). None for
106
+ # every single-value event; `value` stays 0 for an axes event.
107
+ axes: tuple = None
108
+
109
+ @property
110
+ def shift(self) -> bool: return bool(self.modifiers & 1)
111
+
112
+ @property
113
+ def ctrl(self) -> bool: return bool(self.modifiers & 2)
114
+
115
+ @property
116
+ def alt(self) -> bool: return bool(self.modifiers & 4)
117
+
118
+ @property
119
+ def metadata(self) -> bool: return bool(self.modifiers & 8)
120
+
121
+
122
+ @dataclass(slots=True)
123
+ class _InputState:
124
+ is_down: bool = False
125
+ down_time: float = 0.0
126
+ down_x: float = 0.0
127
+ down_y: float = 0.0
128
+ last_up_time: float = 0.0
129
+ click_count: int = 0
130
+ # True when the current press is the SECOND down of a double-click (set in
131
+ # feed_down). Lets a drag off this press dispatch as DOUBLE_DRAGGED.
132
+ is_double_press: bool = False
133
+ # True when this press is a CHORD - a mouse button pressed while the other
134
+ # button is still held (see feed_down). A chorded press is level state
135
+ # only: is_down() reads it, nothing is dispatched for it (no DOWN, no drag
136
+ # events, no HELD, no CLICKED/UP on release). Consumers can inspect
137
+ # button level state without dispatching a competing gesture.
138
+ chord: bool = False
139
+
140
+
141
+ # Missed-release safety (InputHandler._reconcile_held). The backend installs
142
+ # a probe `fn(input_id) -> bool | None` via set_button_probe: True/False =
143
+ # the button's REAL level state, None = unknown (not a platform guess, no
144
+ # platform truth). Module-level and hotswap-survive because the handler
145
+ # instance (Melty.event_handler) outlives both hotswaps and studio restarts.
146
+ _BUTTON_PROBE: dict = globals().get("_BUTTON_PROBE") or {"fn": None}
147
+
148
+
149
+ def set_button_probe(fn):
150
+ _BUTTON_PROBE["fn"] = fn
151
+
152
+
153
+ # Orchestrator record/replay funnel. Every REAL input reaches the handler
154
+ # through the feed_* methods below (the GLFW backend's callbacks and its
155
+ # per-frame cursor sample), so this one tap taps the whole stream: the
156
+ # Orchestrator (view/playback/orchestrator.py) registers
157
+ # `fn(kind, *args) -> bool` here - it records the event while a recording is
158
+ # armed, and returns True to CONSUME the real event while a replay is
159
+ # driving (the mute that keeps a stray real click from corrupting the replay).
160
+ # The key/char callbacks in event_backends.py call the input tap for the
161
+ # events that bypass the handler (Melty.frame_key_events and imgui events).
162
+ # Module-level and hotswap-surviving, same shape as _BUTTON_PROBE.
163
+ _INPUT_TAP: dict = globals().get("_INPUT_TAP") or {"fn": None}
164
+
165
+
166
+ def set_input_tap(fn):
167
+ _INPUT_TAP["fn"] = fn
168
+
169
+
170
+ def input_tap(kind, *args):
171
+ fn = _INPUT_TAP["fn"]
172
+ if fn is None:
173
+ return False
174
+ try:
175
+ return bool(fn(kind, *args))
176
+ except Exception:
177
+ return False
178
+
179
+
180
+ _parse_cache: dict[str, tuple[str, str, bool, bool]] = {} # (input_id, action, inverted, non_blocking)
181
+ _view_id_names_cache: dict[Any, dict[tuple[str, str], str]] = {}
182
+ _view_id_flags_cache: dict[
183
+ Any, dict[tuple[str, str], tuple[bool, bool]]] = {} # view_id, key -> (inverted, non_blocking)
184
+ _view_id_to_tile_id: dict[str, str] = {}
185
+
186
+
187
+ def _strip_flag(name: str, flag: str) -> tuple[str, bool]:
188
+ """Strip a flag word from anywhere in an underscore-delimited name."""
189
+ prefix = flag + "_"
190
+ infix = "_" + flag + "_"
191
+ suffix = "_" + flag
192
+ if name.startswith(prefix):
193
+ return name[len(prefix):], True
194
+ if infix in name:
195
+ return name.replace(infix, "_", 1), True
196
+ if name.endswith(suffix):
197
+ return name[:-len(suffix)], True
198
+ return name, False
199
+
200
+
201
+ # Keyboard-modifier words and their bit (matching InputHandler.set_modifiers:
202
+ # shift=1, ctrl=2, alt=4, meta=8). Emitted/parsed in a fixed order so
203
+ # "ctrl_shift_f" and "shift_ctrl_f" canonicalise to the same input_id.
204
+ _MOD_WORDS = (("ctrl", 2), ("shift", 1), ("alt", 4), ("meta", 8))
205
+
206
+
207
+ def _strip_mods(name: str) -> tuple[str, int]:
208
+ """Strip modifier words (ctrl/shift/alt/meta) from `name`, returning the
209
+ remainder and the combined modifier mask."""
210
+ mask = 0
211
+ for word, bit in _MOD_WORDS:
212
+ name, found = _strip_flag(name, word)
213
+ if found:
214
+ mask |= bit
215
+ return name, mask
216
+
217
+
218
+ def mod_prefix(mods: int) -> str:
219
+ """The canonical "ctrl_shift_…" prefix for a modifier mask (fixed order)."""
220
+ return "".join(f"{word}_" for word, bit in _MOD_WORDS if mods & bit)
221
+
222
+
223
+ def parse_event_name(name: str) -> tuple[str, str, bool, bool]:
224
+ """Parse "left_mouse_up" → ("left_mouse", "up", False, False)
225
+ Parse "inverted_left_mouse_clicked" → ("left_mouse", "clicked", True, False)
226
+ Parse "non_blocking_left_mouse_clicked" → ("left_mouse", "clicked", False, True)
227
+ Parse "ctrl_shift_f_key_down" → ("ctrl_shift_f", "down", False, False) — the
228
+ modifier words are folded (canonically ordered) into the input_id, so a
229
+ modified shortcut dispatches in its own bucket instead of sharing the plain
230
+ key's and being contended/blocked by other subscribers.
231
+ """
232
+ if name in _parse_cache:
233
+ return _parse_cache[name]
234
+
235
+ original = name
236
+ if name.startswith("on_"):
237
+ name = name[3:]
238
+
239
+ # Strip flags, then modifier words (folded back into input_id prefix).
240
+ name, inverted = _strip_flag(name, "inverted")
241
+ name, non_blocking = _strip_flag(name, "non_blocking")
242
+ if not non_blocking:
243
+ name, non_blocking = _strip_flag(name, "nonblocking")
244
+ name, is_double = _strip_flag(name, "double")
245
+ name, mods = _strip_mods(name)
246
+ pfx = mod_prefix(mods)
247
+
248
+ # Check if the name itself is an action (e.g., "hovered", "clicked")
249
+ if name in ALL_ACTIONS:
250
+ canonical = ACTION_ALIASES.get(name, name)
251
+ if is_double:
252
+ canonical = _DOUBLE_PROMOTE.get(canonical, canonical)
253
+ result = (pfx + "cursor", canonical, inverted, non_blocking)
254
+ _parse_cache[original] = result
255
+ return result
256
+
257
+ # Check for action suffix
258
+ for action in _SORTED_ACTIONS:
259
+ if name.endswith(f"_{action}"):
260
+ input_id = name[:-(len(action) + 1)]
261
+ if input_id.endswith("_key"):
262
+ input_id = input_id[:-4]
263
+ canonical = ACTION_ALIASES.get(action, action)
264
+ if is_double:
265
+ canonical = _DOUBLE_PROMOTE.get(canonical, canonical)
266
+ result = (pfx + input_id, canonical, inverted, non_blocking)
267
+ _parse_cache[original] = result
268
+ return result
269
+
270
+ # No action suffix (e.g., "ctrl_z", "left_mouse"): default to DOWN. Emitted
271
+ # events always carry a concrete action, so an empty action would never match
272
+ # and the subscription would silently never fire - a footgun. A bare key or
273
+ # mouse name means "this went down".
274
+ result = (pfx + name, EventAction.DOWN, inverted, non_blocking)
275
+ _parse_cache[original] = result
276
+ return result
277
+
278
+
279
+ class InputHandler:
280
+ """
281
+ Usage:
282
+ handler = InputHandler()
283
+
284
+ handler.begin_frame()
285
+ handler.register_hovered("btn", ["left_mouse_clicked"])
286
+ handler.register_hovered("panel", ["left_mouse_dragged"], priority=1)
287
+
288
+ # Inverted priority (parent/root views fire first):
289
+ handler.register_hovered("root", ["inverted_left_mouse_clicked"])
290
+
291
+ # Feed from backend
292
+ handler.feed_down("left_mouse", x, y)
293
+ handler.feed_move(x, y)
294
+ handler.feed_up("left_mouse", x, y)
295
+
296
+ events = handler.process_frame()
297
+ # {"btn": {"left_mouse_clicked": InputEvent(...)}, ...}
298
+ """
299
+
300
+ __slots__ = (
301
+ '_states', '_hovered', '_prev_hovered', '_pending', '_cursor_x', '_cursor_y',
302
+ '_modifiers', '_last_dx', '_last_dy', '_drag_capture', '_drag_activated',
303
+ '_down_origins', '_blocker_views', '_pending_clicks',
304
+ '_view_cursor', '_drag_cursor', 'cursor_shape'
305
+ )
306
+
307
+ def __init__(self):
308
+ self._states: dict[str, _InputState] = {}
309
+ self._hovered: list[tuple[Any, int, frozenset]] = []
310
+ self._prev_hovered: dict[Any, tuple[int, frozenset]] = {} # view_id → (priority, subscriptions)
311
+ self._pending: list[InputEvent] = []
312
+ self._cursor_x = 0.0
313
+ self._cursor_y = 0.0
314
+ self._modifiers = 0
315
+ self._last_dx = 0.0
316
+ self._last_dy = 0.0
317
+ self._drag_capture: dict[str, Any] = {} # input_id -> (view_id, drag_action) captured on down
318
+ self._drag_activated: dict[str, bool] = {} # input_id -> whether drag threshold exceeded
319
+ self._down_origins: dict[str, set] = {} # input_id -> set of view_ids hovered at down time
320
+ self._blocker_views: set = set()
321
+ # CLICKED events held back to disambiguate single vs double, but ONLY for
322
+ # inputs that have a double-click/double-drag subscriber hovered (so plain
323
+ # clicks elsewhere keep zero latency). input_id -> (deadline, event,
324
+ # [(view_id, key), ...] targets resolved at defer time). Flushed when the
325
+ # double-click window expires with no double; cancelled when a 2nd press
326
+ # (is_double_press) or a DOUBLE_CLICKED for that input arrives.
327
+ self._pending_clicks: dict[str, tuple] = {}
328
+ # Mouse-cursor shapes (see gl_gui/mouse_cursor.py). view_id -> shape
329
+ # registered this frame via register_hovered(cursor=); input_id ->
330
+ # the shape that was SHOWING when that input's drag was captured
331
+ # (sticky until release); and this frame's resolved shape (None =
332
+ # nothing asked, the last shape).
333
+ self._view_cursor: dict[Any, tuple] = {} # view_id -> (shape, rect | None)
334
+ self._drag_cursor: dict[str, Any] = {}
335
+ self.cursor_shape = None
336
+
337
+ def _reconcile_held(self, t: float):
338
+ """Drop any press the handler still holds that the platform says is
339
+ UP. is_down only ever clears through feed_up, and a RELEASE can be
340
+ lost — a freeze (the compositor breaks the implicit grab and hands the
341
+ release elsewhere), a restart mid-press reusing the persistent
342
+ handler, an exception in the button callback. Without this the drag
343
+ captured on the DOWN fires DRAGGED every frame with nothing held, and
344
+ the window / selection / column edge stays glued to the cursor.
345
+ Synthesizing feed_up runs the normal release path (UP, DRAG_RELEASED,
346
+ capture unlatched, cursor unpinned)."""
347
+ probe = _BUTTON_PROBE.get("fn")
348
+ if probe is None:
349
+ return
350
+ for input_id, state in list(self._states.items()):
351
+ if not state.is_down:
352
+ continue
353
+ try:
354
+ really_down = probe(input_id)
355
+ except Exception:
356
+ really_down = None
357
+ if really_down is False:
358
+ self.feed_up(input_id, self._cursor_x, self._cursor_y, t)
359
+
360
+ def _state(self, input_id: str) -> _InputState:
361
+ s = self._states.get(input_id)
362
+ if s is None:
363
+ s = _InputState()
364
+ self._states[input_id] = s
365
+ return s
366
+
367
+ def set_modifiers(self, shift=False, ctrl=False, alt=False, meta=False):
368
+ self._modifiers = (shift and 1) | (ctrl and 2) | (alt and 4) | (meta and 8)
369
+
370
+ def begin_frame(self):
371
+ self._hovered.clear()
372
+ self._pending.clear()
373
+ self._last_dx = 0.0
374
+ self._last_dy = 0.0
375
+ self._blocker_views.clear()
376
+ self._view_cursor.clear()
377
+
378
+ def register_hovered(self, view_id: Any, subscribed: list[str], priority: int = 0, tile_id=None, selected=False, blocker=False, cursor=None, cursor_rect=None, cursor_gate=None):
379
+ """Register hovered view. Priority 0 = topmost.
380
+
381
+ cursor=<imgui MOUSE_CURSOR_*> names the pointer shape to show while
382
+ this view is the topmost cursor-carrying hovered view (resolved in
383
+ process_frame, same blocker/z rules as events). cursor_rect=(l, t, r, b)
384
+ is the screen rect the shape covers: process_frame re-tests it against
385
+ the LATEST pointer position, so a registration made at the start of a
386
+ slow frame drops the moment the pointer has left (None = trust the
387
+ hover test that registered it). An empty `subscribed` list is
388
+ allowed: a cursor-only registration. cursor_gate=<event name>
389
+ ("left_mouse_dragged") shows the shape only while THIS view is the
390
+ subscriber that event would resolve to first — the one a press here
391
+ would hand the drag to — so a drag handle's shape never shows over
392
+ a child that takes the drag itself (gl_gui/mouse_cursor.py).
393
+
394
+ Multiple calls with the same view_id will merge subscriptions,
395
+ using the lowest (best) priority.
396
+
397
+ Including "inverted" in a subscription name (e.g. "inverted_left_mouse_clicked")
398
+ causes that event to fire to the highest-priority-number (parent/root) view first,
399
+ reversing the normal child-first dispatch order.
400
+
401
+ blocker=True makes this view consume all events at its priority level.
402
+ Views with a higher priority number (lower priority) than the topmost
403
+ blocker will not receive any events. Inverted events are scoped to
404
+ within the blocker boundary.
405
+ """
406
+ if blocker:
407
+ self._blocker_views.add(view_id)
408
+ if cursor is not None:
409
+ gate_key = None
410
+ if cursor_gate is not None:
411
+ gate_input_id, gate_action, _inv, _nb = parse_event_name(cursor_gate)
412
+ gate_key = (gate_input_id, gate_action)
413
+ self._view_cursor[view_id] = (cursor, cursor_rect, gate_key)
414
+ # Stamped even on a cursor-only (empty subscribed) registration: the
415
+ # blocker pass keeps a blocker's OWN tile by this map.
416
+ _view_id_to_tile_id[view_id] = tile_id
417
+
418
+ # Parse new subscriptions
419
+ new_subs = set()
420
+ scroll_override = False
421
+ for s in subscribed:
422
+ if selected and s == "scroll_y_changed":
423
+ priority -= 20
424
+ # A *selected* view's scroll is the zoom-override gesture
425
+ # (e.g. draw_texture): the -20 boost is meant to out-prioritize
426
+ # its scroll parent so the wheel zooms instead of scrolling the
427
+ # list. Mark it so the merge below preserves the boost.
428
+ scroll_override = True
429
+ input_id, action, inverted, non_blocking = parse_event_name(s)
430
+ sub = (input_id, action)
431
+ if view_id not in _view_id_names_cache:
432
+ _view_id_names_cache[view_id] = {}
433
+ _view_id_flags_cache[view_id] = {}
434
+ _view_id_names_cache[view_id][sub] = s
435
+ _view_id_flags_cache[view_id][sub] = (inverted, non_blocking)
436
+ _view_id_to_tile_id[view_id] = tile_id
437
+ new_subs.add(sub)
438
+
439
+ # Check if view already registered this frame - merge if so
440
+ for i, (vid, pri, subs) in enumerate(self._hovered):
441
+ if vid == view_id:
442
+ merged_subs = subs | frozenset(new_subs)
443
+ # A view_id collapses to ONE priority for all its subs, so by
444
+ # default keep the WORST (max) - this stops a single boosted
445
+ # subscription (e.g. a deeply-nested child's select) from
446
+ # silently stealing the wheel from its scroll parent.
447
+ #
448
+ # EXCEPTION: the selected-scroll override. The render wrapper
449
+ # registers that view's event params (incl. its -20 scroll boost)
450
+ # under the bare tile_id, the SAME id the select gesture and the
451
+ # right-click menu register on at baseline priority. With max()
452
+ # that baseline wins the merge and erases the boost, so the
453
+ # parent's view_scroll recaptures the wheel and scrolling the
454
+ # selected child clears its own selection - the override never
455
+ # fires. The boosted scroll registration is the child's last
456
+ # tile_id registration, so taking the best (min) here preserves
457
+ # the boost without affecting the non-selected case.
458
+ if scroll_override:
459
+ merged_priority = min(pri, priority)
460
+ else:
461
+ merged_priority = max(pri, priority)
462
+ self._hovered[i] = (view_id, merged_priority, merged_subs)
463
+ return
464
+
465
+ # New view
466
+ self._hovered.append((view_id, priority, frozenset(new_subs)))
467
+
468
+ def _emit(self, input_id: str, action: str, x: float, y: float,
469
+ dx: float = 0, dy: float = 0, value: float = 0, t: float = None):
470
+ tile_id = _view_id_to_tile_id.get(input_id, None)
471
+ self._pending.append(InputEvent(
472
+ input_id, action, tile_id, x, y, dx, dy, value,
473
+ t or time.perf_counter(), self._modifiers
474
+ ))
475
+
476
+ def feed_down(self, input_id: str, x: float = None, y: float = None, t: float = None):
477
+ t = t or time.perf_counter()
478
+ x = self._cursor_x if x is None else x
479
+ y = self._cursor_y if y is None else y
480
+
481
+ if input_tap("down", input_id, x, y):
482
+ return
483
+
484
+ state = self._state(input_id)
485
+ state.chord = False
486
+
487
+ # Suppress competing mouse-button gestures. A left press during
488
+ # right-drag must not start selection, pickup or window movement.
489
+ # This preserves button level state; it does not select a resize
490
+ # corner. Plain/double right-drag select bottom-right/top-left:
491
+ # - left pressed while right is held → the left press is a chord:
492
+ # level state only, nothing dispatched for it, its release silent.
493
+ # - right pressed while left is held but NOT yet dragging (both
494
+ # buttons pressed together, left arriving a few ms first) → the
495
+ # left press becomes the chord retroactively (its capture and any
496
+ # still-queued events are withdrawn) and the right press proceeds as
497
+ # a normal right-drag start with the left already down.
498
+ # - right pressed while a LEFT DRAG is already active (window corner,
499
+ # column edge, selection) → the right press is the chord: swallowed,
500
+ # so it can't trigger a second gesture on top of the first.
501
+ if input_id == "left_mouse":
502
+ other = self._states.get("right_mouse")
503
+ if other is not None and other.is_down:
504
+ state.is_down = True
505
+ state.chord = True
506
+ state.down_time = t
507
+ state.down_x = x
508
+ state.down_y = y
509
+ state.is_double_press = False
510
+ return
511
+ elif input_id == "right_mouse":
512
+ other = self._states.get("left_mouse")
513
+ if other is not None and other.is_down and not other.chord:
514
+ if self._drag_activated.get("left_mouse", False):
515
+ state.is_down = True
516
+ state.chord = True
517
+ state.down_time = t
518
+ state.down_x = x
519
+ state.down_y = y
520
+ state.is_double_press = False
521
+ return
522
+ other.chord = True
523
+ other.is_double_press = False
524
+ self._drag_capture.pop("left_mouse", None)
525
+ self._drag_activated.pop("left_mouse", None)
526
+ self._down_origins.pop("left_mouse", None)
527
+ self._pending = [e for e in self._pending
528
+ if e.input_id != "left_mouse"]
529
+
530
+ # A "double press" is the SECOND down of a double-click: it's a
531
+ # recent click (click_count=1, within DOUBLE_CLICK_WINDOW of the last
532
+ # release) landing near the prior press. Recorded so a drag off this
533
+ # down dispatches as DOUBLE_DRAGGED. Distance is measured against the
534
+ # previous down_x, so this must run before down_x/y are reset.
535
+ dist = ((x - state.down_x) ** 2 + (y - state.down_y) ** 2) ** 0.5
536
+ state.is_double_press = (
537
+ state.click_count >= 1
538
+ and (t - state.last_up_time) <= DOUBLE_CLICK_WINDOW
539
+ and dist <= CLICK_MAX_DISTANCE
540
+ )
541
+ state.is_down = True
542
+ state.down_time = t
543
+ state.down_x = x
544
+ state.down_y = y
545
+
546
+ self._emit(input_id, EventAction.DOWN, x, y, t=t)
547
+
548
+ def feed_up(self, input_id: str, x: float = None, y: float = None, t: float = None):
549
+ t = t or time.perf_counter()
550
+ x = self._cursor_x if x is None else x
551
+ y = self._cursor_y if y is None else y
552
+
553
+ if input_tap("up", input_id, x, y):
554
+ return
555
+
556
+ state = self._state(input_id)
557
+ was_down = state.is_down
558
+ state.is_down = False
559
+
560
+ if state.chord:
561
+ # Releasing a chorded button: level state only - no UP, no click
562
+ # (and no click_count / last_up_time bookkeeping, so it can't seed
563
+ # a double-click either).
564
+ state.chord = False
565
+ return
566
+
567
+ # UP carries the travel since its press (total_dx/dy), so a subscriber
568
+ # can tell a clean release from the end of a drag without waiting for
569
+ # CLICKED - which is held back for the double-click window whenever a
570
+ # double subscriber is hovered (the wrapper's corner double right-drag
571
+ # covers every window, so every right CLICKED waits 250 ms). The
572
+ # context menu opens with this event instead.
573
+ travel = (x - state.down_x, y - state.down_y) if was_down else (0.0, 0.0)
574
+ self._pending.append(InputEvent(
575
+ input_id, EventAction.UP, _view_id_to_tile_id.get(input_id, None), x, y,
576
+ 0, 0, 0, t, self._modifiers, travel[0], travel[1]
577
+ ))
578
+
579
+ if was_down:
580
+ dist = ((x - state.down_x) ** 2 + (y - state.down_y) ** 2) ** 0.5
581
+
582
+ # Click if didn't move too far (no duration limit)
583
+ if dist <= CLICK_MAX_DISTANCE:
584
+ if t - state.last_up_time <= DOUBLE_CLICK_WINDOW:
585
+ state.click_count += 1
586
+ if state.click_count >= 2:
587
+ self._emit(input_id, EventAction.DOUBLE_CLICKED, x, y, t=t)
588
+ state.click_count = 0
589
+ else:
590
+ state.click_count = 1
591
+ self._emit(input_id, EventAction.CLICKED, x, y, t=t)
592
+ else:
593
+ state.click_count = 0
594
+
595
+ state.last_up_time = t
596
+
597
+ def feed_move(self, x: float, y: float, dx: float = None, dy: float = None, t: float = None):
598
+ t = t or time.perf_counter()
599
+ dx = x - self._cursor_x if dx is None else dx
600
+ dy = y - self._cursor_y if dy is None else dy
601
+ if input_tap("move", x, y):
602
+ return
603
+ self._cursor_x, self._cursor_y = x, y
604
+ self._last_dx = dx
605
+ self._last_dy = dy
606
+
607
+ self._emit("cursor", EventAction.MOVED, x, y, dx, dy, t=t)
608
+
609
+ def feed_change(self, input_id: str, value: float, t: float = None):
610
+ if input_tap("change", input_id, value):
611
+ return
612
+ # Coalesce repeated CHANGED events for the same input within a frame by
613
+ # summing their values. Scroll-wheel notches arrive as separate
614
+ # callbacks; when the framerate drops, many come between two
615
+ # process_frame() calls. Dispatch keys events by name and overwrites
616
+ # (add_event: vdict[event_name] = event), so without coalescing only the
617
+ # last notch's delta would persist and the rest of the scroll distance
618
+ # would be lost. Summing carries the accumulated scroll delta intact, the
619
+ # same way Melty.frame_key_events preserves every keystroke under load.
620
+ for e in self._pending:
621
+ if e.input_id == input_id and e.action == EventAction.CHANGED:
622
+ e.value += value
623
+ if t is not None:
624
+ e.timestamp = t
625
+ return
626
+ self._emit(input_id, EventAction.CHANGED, self._cursor_x, self._cursor_y, value=value, t=t)
627
+
628
+ def feed_axes(self, input_id: str, axes, t: float = None):
629
+ """A multi-axis CHANGED event — the 3D mouse's six axes in one
630
+ InputEvent (`event.axes`), dispatched like any CHANGED input: to the
631
+ topmost hovered view subscribed to "<input_id>_changed" (draw_voxels
632
+ declares `space_mouse_changed=None`). Coalesced per frame by summing
633
+ each component, the scroll rule: the reader feeds deflection × dt,
634
+ so a slow frame that gathers several samples hands the view their
635
+ integral, and nothing is dropped."""
636
+ axes = tuple(float(a) for a in axes)
637
+ if input_tap("axes", input_id, axes):
638
+ return
639
+ for e in self._pending:
640
+ if e.input_id == input_id and e.action == EventAction.CHANGED:
641
+ e.axes = tuple(a + b for a, b in zip(e.axes or (0.0,) * len(axes), axes))
642
+ if t is not None:
643
+ e.timestamp = t
644
+ return
645
+ self._emit(input_id, EventAction.CHANGED, self._cursor_x, self._cursor_y, t=t)
646
+ self._pending[-1].axes = axes
647
+
648
+ @staticmethod
649
+ def _resolve_subscribers(
650
+ key: tuple[str, str],
651
+ index: dict[tuple[str, str], list[tuple[Any, int]]],
652
+ ) -> list[Any]:
653
+ """Resolve subscriber chain from precomputed index.
654
+
655
+ Normal: lowest priority first (child-first).
656
+ Inverted: highest priority first (parent-first).
657
+ Non-blocking: collects multiple views until a blocking one.
658
+ """
659
+ subscribers = index.get(key)
660
+ if not subscribers:
661
+ return ()
662
+
663
+ # Single subscriber fast path (most common case)
664
+ if len(subscribers) == 1:
665
+ return (subscribers[0][0],)
666
+
667
+ # Check flags
668
+ any_inverted = False
669
+ any_non_blocking = False
670
+ flags_cache_get = _view_id_flags_cache.get
671
+ for v, _ in subscribers:
672
+ flags = flags_cache_get(v)
673
+ if flags:
674
+ f = flags.get(key)
675
+ if f:
676
+ any_inverted = any_inverted or f[0]
677
+ any_non_blocking = any_non_blocking or f[1]
678
+ if any_inverted and any_non_blocking:
679
+ break
680
+
681
+ # If no special flags, first subscriber wins (already sorted by priority asc)
682
+ if not any_inverted and not any_non_blocking:
683
+ return (subscribers[0][0],)
684
+
685
+ # Need to reorder or walk chain
686
+ ordered = subscribers
687
+ if any_inverted:
688
+ ordered = sorted(subscribers, key=lambda x: x[1], reverse=True)
689
+
690
+ if not any_non_blocking:
691
+ return (ordered[0][0],)
692
+
693
+ # Walk non-blocking chain
694
+ result = []
695
+ for v, _ in ordered:
696
+ result.append(v)
697
+ flags = flags_cache_get(v)
698
+ if flags:
699
+ f = flags.get(key)
700
+ if not f or not f[1]:
701
+ break
702
+ else:
703
+ break
704
+ return result
705
+
706
+ def process_frame(self, on_pointer_down=None):
707
+ """Returns {view_id: {event_name: event}} for all matched subscriptions.
708
+
709
+ on_pointer_down observes each left/right press before dispatch, even
710
+ without subscribers. It must not consume events or change registrations.
711
+ """
712
+ self._hovered.sort(key=lambda x: x[1])
713
+
714
+ # --- Blocker: drop views below the topmost blocker ---
715
+ # A blocker (closable window) stops events reaching anything stacked
716
+ # below it. Exception: non_blocking subscriptions survive - they're
717
+ # pass-through global handlers (e.g. an app-wide shortcut on the root),
718
+ # which shouldn't be swallowed just because a window is in front. Such a
719
+ # non-blocker view is kept, but only its non_blocking subs.
720
+ if self._blocker_views:
721
+ blocker_priority = None
722
+ blocker_tile = None
723
+ for view_id, priority, subs in self._hovered:
724
+ if view_id in self._blocker_views:
725
+ blocker_priority = priority
726
+ blocker_tile = _view_id_to_tile_id.get(view_id)
727
+ break # list is sorted asc, first match is topmost
728
+ if blocker_priority is not None:
729
+ flags_get = _view_id_flags_cache.get
730
+ kept = []
731
+ for v, p, s in self._hovered:
732
+ if p <= blocker_priority or _view_id_to_tile_id.get(v) == blocker_tile:
733
+ kept.append((v, p, s))
734
+ continue
735
+ vf = flags_get(v)
736
+ if vf:
737
+ passthrough = frozenset(sub for sub in s if vf.get(sub, (False, False))[1])
738
+ if passthrough:
739
+ kept.append((v, p, passthrough))
740
+ self._hovered = kept
741
+
742
+ # --- Precompute key → [(view_id, priority)] index (sorted by priority asc) ---
743
+ key_index: dict[tuple[str, str], list[tuple[Any, int]]] = {}
744
+ for view_id, priority, subs in self._hovered:
745
+ vp = (view_id, priority)
746
+ for key in subs:
747
+ bucket = key_index.get(key)
748
+ if bucket is None:
749
+ key_index[key] = [vp]
750
+ else:
751
+ bucket.append(vp)
752
+
753
+ resolve = self._resolve_subscribers
754
+ result: dict[Any, dict[str, InputEvent]] = {}
755
+ result_by_type: dict[Any, dict[str, InputEvent]] = {}
756
+ t = time.perf_counter()
757
+ self._reconcile_held(t)
758
+
759
+ # Build current hover dict with priorities
760
+ current_hovered: dict[Any, tuple[int, frozenset]] = {
761
+ view_id: (priority, subs) for view_id, priority, subs in self._hovered
762
+ }
763
+
764
+ # Bind frequently-used lookups to locals
765
+ names_cache_get = _view_id_names_cache.get
766
+ tile_cache_get = _view_id_to_tile_id.get
767
+ cx, cy = self._cursor_x, self._cursor_y
768
+ mods = self._modifiers
769
+
770
+ def add_event(view_id: Any, key: tuple[str, str], event: InputEvent):
771
+ cache = names_cache_get(view_id)
772
+ tile_id = _view_id_to_tile_id.get(view_id, None)
773
+ if cache is None:
774
+ return
775
+ event_name = cache.get(key)
776
+ if event_name is None:
777
+ return
778
+ vdict = result.get(view_id)
779
+ if vdict is None:
780
+ vdict = {}
781
+ result[view_id] = vdict
782
+ tdict = result_by_type.get(event_name)
783
+ if tdict is None:
784
+ tdict = {}
785
+ result_by_type[event_name] = tdict
786
+ vdict[event_name] = event
787
+ tdict[view_id] = event
788
+
789
+ # --- Hover events ---
790
+ hover_enter_key = ("cursor", EventAction.HOVER_ENTER)
791
+ hovered_key = ("cursor", EventAction.HOVERED)
792
+ hover_exit_key = ("cursor", EventAction.HOVER_EXIT)
793
+
794
+ # Build index for newly-entered views (for enter events)
795
+ prev_hovered = self._prev_hovered
796
+ newly_index: dict[tuple[str, str], list[tuple[Any, int]]] = {}
797
+ for view_id, priority, subs in self._hovered:
798
+ if view_id not in prev_hovered:
799
+ vp = (view_id, priority)
800
+ for key in subs:
801
+ bucket = newly_index.get(key)
802
+ if bucket is None:
803
+ newly_index[key] = [vp]
804
+ else:
805
+ bucket.append(vp)
806
+
807
+ enter_views = resolve(hover_enter_key, newly_index)
808
+ hovered_views = resolve(hovered_key, key_index)
809
+
810
+ # Build index for exited views
811
+ exit_index: dict[tuple[str, str], list[tuple[Any, int]]] = {}
812
+ for v, (p, subs) in prev_hovered.items():
813
+ if v not in current_hovered:
814
+ vp = (v, p)
815
+ for key in subs:
816
+ bucket = exit_index.get(key)
817
+ if bucket is None:
818
+ exit_index[key] = [vp]
819
+ else:
820
+ bucket.append(vp)
821
+ # Sort exit buckets by priority
822
+ for bucket in exit_index.values():
823
+ if len(bucket) > 1:
824
+ bucket.sort(key=lambda x: x[1])
825
+ exit_views = resolve(hover_exit_key, exit_index)
826
+
827
+ # Emit hover events
828
+ for v in enter_views:
829
+ add_event(v, hover_enter_key, InputEvent("cursor", None, EventAction.HOVER_ENTER, cx, cy, 0, 0, 0, t, mods))
830
+ for v in hovered_views:
831
+ add_event(v, hovered_key, InputEvent("cursor", None, EventAction.HOVERED, cx, cy, 0, 0, 0, t, mods))
832
+ for v in exit_views:
833
+ add_event(v, hover_exit_key, InputEvent("cursor", None, EventAction.HOVER_EXIT, cx, cy, 0, 0, 0, t, mods))
834
+
835
+ # Update previous hover for next frame
836
+ self._prev_hovered = current_hovered
837
+
838
+ # --- Regular events ---
839
+ # Hoist import once (sys.modules lookup still has overhead in a loop)
840
+ from meltygui.core.melty import Melty
841
+ from meltygui.core.windowing.glfw_utils import request_render
842
+ get_latest_mouse = Melty.get_latest_mouse
843
+
844
+ drag_capture = self._drag_capture
845
+ drag_activated = self._drag_activated
846
+ down_origins = self._down_origins
847
+ states = self._states
848
+ pending_clicks = self._pending_clicks
849
+ last_dx, last_dy = self._last_dx, self._last_dy
850
+
851
+ # Inputs that completed a double-click THIS frame - their pending CLICKED
852
+ # (emitted alongside the DOUBLE_CLICKED on the 2nd up) is absorbed.
853
+ doubled_this_frame = {e.input_id for e in self._pending
854
+ if e.action == EventAction.DOUBLE_CLICKED}
855
+
856
+ def _click_targets(ev):
857
+ """Resolve (view_id, key) pairs a CLICKED would dispatch to NOW, so a
858
+ deferred click replays to the same views regardless of later hover."""
859
+ ck = (ev.input_id, EventAction.CLICKED)
860
+ out = [(v, ck) for v in resolve(ck, key_index)]
861
+ if ev.modifiers:
862
+ mk = (mod_prefix(ev.modifiers) + ev.input_id, EventAction.CLICKED)
863
+ if mk != ck:
864
+ out += [(v, mk) for v in resolve(mk, key_index)]
865
+ return out
866
+
867
+ for event in self._pending:
868
+ key = (event.input_id, event.action)
869
+ action = event.action
870
+
871
+ # On DOWN, capture drag target and record origin views. A double-
872
+ # press (the 2nd down of a double-click) prefers DOUBLE_DRAGGED
873
+ # subscribers and only falls back to plain DRAGGED, so a double-drag
874
+ # gesture still drags normally where nothing wants the double form.
875
+ # The captured action is stored so the activation + release passes
876
+ # emit the matching DRAGGED/DOUBLE_DRAGGED variant.
877
+ if action == EventAction.DOWN:
878
+ # Window activation observes the press once, independently of
879
+ # which control consumes it and captures the subsequent drag.
880
+ if on_pointer_down is not None and event.input_id in ("left_mouse", "right_mouse"):
881
+ on_pointer_down(event)
882
+ st = states.get(event.input_id)
883
+ drag_action = EventAction.DRAGGED
884
+ capture_views = None
885
+ if st is not None and st.is_double_press:
886
+ capture_views = resolve((event.input_id, EventAction.DOUBLE_DRAGGED), key_index)
887
+ if capture_views:
888
+ drag_action = EventAction.DOUBLE_DRAGGED
889
+ if not capture_views:
890
+ capture_views = resolve((event.input_id, EventAction.DRAGGED), key_index)
891
+ if capture_views:
892
+ drag_capture[event.input_id] = (capture_views[0], drag_action)
893
+ drag_activated[event.input_id] = False
894
+ # The shape showing at the press sticks through the drag.
895
+ self._drag_cursor[event.input_id] = self.cursor_shape
896
+
897
+ # Record all currently hovered views as origin for this input
898
+ down_origins[event.input_id] = {v for v, _, _ in self._hovered}
899
+
900
+ # On UP, emit drag_released only if drag was activated. The release
901
+ # variant mirrors the captured drag variant (double-drag → double).
902
+ elif action == EventAction.UP:
903
+ cap = drag_capture.pop(event.input_id, None)
904
+ was_activated = drag_activated.pop(event.input_id, False)
905
+ down_origins.pop(event.input_id, None)
906
+ self._drag_cursor.pop(event.input_id, None)
907
+ if cap is not None and was_activated:
908
+ captured_view, drag_action = cap
909
+ rel_action = (EventAction.DOUBLE_DRAG_RELEASED
910
+ if drag_action == EventAction.DOUBLE_DRAGGED
911
+ else EventAction.DRAG_RELEASED)
912
+ drag_released_key = (event.input_id, rel_action)
913
+
914
+ lx, ly = get_latest_mouse()
915
+ state = states.get(event.input_id)
916
+ total_dx = lx - state.down_x if state else 0.0
917
+ total_dy = ly - state.down_y if state else 0.0
918
+
919
+ release_event = InputEvent(
920
+ event.input_id, rel_action, event.tile_id, lx, ly,
921
+ last_dx, last_dy, 0, t, mods, total_dx, total_dy
922
+ )
923
+ add_event(captured_view, drag_released_key, release_event)
924
+
925
+ if state:
926
+ state.down_x = 0.0
927
+ state.down_y = 0.0
928
+ state.down_time = 0.0
929
+
930
+ # Single/double-click disambiguation. Only kicks in when a double
931
+ # subscriber for this input is hovered - otherwise clicks dispatch
932
+ # immediately (zero latency) as before.
933
+ elif action == EventAction.CLICKED:
934
+ X = event.input_id
935
+ if X in doubled_this_frame:
936
+ # A double-click completed THIS frame.
937
+ pend = pending_clicks.pop(X, None)
938
+ if key_index.get((X, EventAction.DOUBLE_CLICKED)):
939
+ # A real double-click consumer (e.g. the voxel params
940
+ # panel on left double-click) handles it - drop the
941
+ # single click so it doesn't ALSO fire.
942
+ continue
943
+ # No double-click consumer (this input only has a double-DRAG
944
+ # gesture, e.g. right_mouse). A double-click with no drag is
945
+ # just one click - fire the ONE (the deferred first press)
946
+ # and drop this second CLICKED.
947
+ if pend is not None:
948
+ _, ev1, targets1 = pend
949
+ for v, k in targets1:
950
+ add_event(v, k, ev1)
951
+ continue
952
+ # else fall through: emit this lone click once.
953
+ elif (key_index.get((X, EventAction.DOUBLE_CLICKED))
954
+ or key_index.get((X, EventAction.DOUBLE_DRAGGED))):
955
+ # Hold this click until the window expires. Cancelled by a
956
+ # double-DRAG activating (drag pass) or a double-click
957
+ # completing (above); otherwise it flushes as a single click.
958
+ pending_clicks[X] = (event.timestamp + DOUBLE_CLICK_WINDOW,
959
+ event, _click_targets(event))
960
+ continue
961
+
962
+ for v in resolve(key, key_index):
963
+ add_event(v, key, event)
964
+
965
+ # Modifier-qualified subscribers (e.g. "ctrl_shift_f_down") live in
966
+ # their own bucket keyed by a "ctrl_shift_..."-prefixed input ID, so
967
+ # resolve that too when modifiers are held. The plain bucket above
968
+ # still fires (legacy "fire on any mods" behaviour), so a view can
969
+ # subscribe either way.
970
+ if event.modifiers:
971
+ mkey = (mod_prefix(event.modifiers) + event.input_id, event.action)
972
+ if mkey != key:
973
+ for v in resolve(mkey, key_index):
974
+ add_event(v, mkey, event)
975
+
976
+ # --- Continuous held events (only to views hovered at DOWN time) ---
977
+ for input_id, state in states.items():
978
+ if not state.is_down:
979
+ continue
980
+ origins = down_origins.get(input_id)
981
+ if not origins:
982
+ continue
983
+ held_key = (input_id, EventAction.HELD)
984
+ held_subs = resolve(held_key, key_index)
985
+ if held_subs:
986
+ lx, ly = get_latest_mouse()
987
+ total_dx = lx - state.down_x
988
+ total_dy = ly - state.down_y
989
+ for v in held_subs:
990
+ if v not in origins:
991
+ continue
992
+ tile_id = tile_cache_get(v, None)
993
+ held_event = InputEvent(
994
+ input_id, EventAction.HELD, tile_id, lx, ly,
995
+ last_dx, last_dy, 0, t, mods, total_dx, total_dy
996
+ )
997
+ add_event(v, held_key, held_event)
998
+
999
+ # --- Continuous drag events (after threshold) ---
1000
+ # Emits the action captured on DOWN (DRAGGED or its DOUBLE_DRAGGED form).
1001
+ drag_threshold_sq = DRAG_THRESHOLD * DRAG_THRESHOLD
1002
+ for input_id, state in states.items():
1003
+ if not state.is_down:
1004
+ continue
1005
+ cap = drag_capture.get(input_id)
1006
+ if cap is None:
1007
+ continue
1008
+ captured_view, drag_action = cap
1009
+
1010
+ lx, ly = get_latest_mouse()
1011
+ total_dx = lx - state.down_x
1012
+ total_dy = ly - state.down_y
1013
+
1014
+ if not drag_activated.get(input_id, False):
1015
+ if total_dx * total_dx + total_dy * total_dy < drag_threshold_sq:
1016
+ continue
1017
+ drag_activated[input_id] = True
1018
+ # A drag is not a click - drop any single click deferred for this
1019
+ # input (the 1st press of a double-drag) so the drag doesn't
1020
+ # also open the context panel when it ends.
1021
+ pending_clicks.pop(input_id, None)
1022
+
1023
+ drag_key = (input_id, drag_action)
1024
+ tile_id = tile_cache_get(captured_view, None)
1025
+ drag_event = InputEvent(
1026
+ input_id, drag_action, tile_id, lx, ly,
1027
+ last_dx, last_dy, 0, t, mods, total_dx, total_dy
1028
+ )
1029
+ add_event(captured_view, drag_key, drag_event)
1030
+
1031
+ # --- Flush deferred clicks whose double-click window expired with no
1032
+ # double. Dispatch into THIS frame's result (meltygui's begin_frame then
1033
+ # invalidates the target tile so a cached view re-renders + consumes it).
1034
+ # While anything is still pending, keep the render loop alive so the
1035
+ # deadline is actually reached even if the app would otherwise idle. ----
1036
+ if pending_clicks:
1037
+ # Don't flush while the button is held again (a 2nd click in
1038
+ # progress) - wait for its release so a double-click/drag can't
1039
+ # claim it.
1040
+ for X in [k for k, v in pending_clicks.items()
1041
+ if t >= v[0] and not (k in states and states[k].is_down)]:
1042
+ _, ev, targets = pending_clicks.pop(X)
1043
+ for v, k in targets:
1044
+ add_event(v, k, ev)
1045
+ if pending_clicks:
1046
+ request_render()
1047
+
1048
+ # --- Mouse-cursor shape (gl_gui/mouse_cursor.py) ---
1049
+ # A captured drag pins the shape that was showing at its press -
1050
+ # and mutes hover shapes afterwards (dragging a window across an
1051
+ # icon must not flash the I-beam). Otherwise the topmost hovered
1052
+ # view carrying a cursor wins; _hovered is priority-sorted and
1053
+ # already blocker-pruned, so a covered view never shows its shape.
1054
+ shape = None
1055
+ if drag_capture:
1056
+ for input_id in drag_capture:
1057
+ c = self._drag_cursor.get(input_id)
1058
+ if c is not None:
1059
+ shape = c
1060
+ break
1061
+ else:
1062
+ # Registrations are from the previous draw pass (hit-tested at
1063
+ # that frame's pointer). Re-check each shape's rect with the
1064
+ # pointer as it is NOW - the draw just ran - so the shape can
1065
+ # never outlive the pointer leaving its rect by a slow frame.
1066
+ # (A view the pointer has newly entered shows its shape once it
1067
+ # registers next frame; never sticking beats one frame of arrow.)
1068
+ vc = self._view_cursor
1069
+ pointer_x, pointer_y = self._cursor_x, self._cursor_y
1070
+ gate_owner = {} # gate key -> the view that event resolves to first
1071
+ for v, _, _ in self._hovered:
1072
+ entry = vc.get(v)
1073
+ if entry is None:
1074
+ continue
1075
+ c, rect, gate_key = entry
1076
+ if rect is not None and not (rect[0] <= pointer_x <= rect[2]
1077
+ and rect[1] <= pointer_y <= rect[3]):
1078
+ continue
1079
+ if gate_key is not None:
1080
+ # A gated shape (a window's MOVE handle) belongs to the
1081
+ # view that would CAPTURE the gate event - the same
1082
+ # process the press uses for drag_capture above - so
1083
+ # a child that takes the drag itself hides it, and the
1084
+ # walk goes on to the shape below.
1085
+ if gate_key not in gate_owner:
1086
+ owners = resolve(gate_key, key_index)
1087
+ gate_owner[gate_key] = owners[0] if owners else None
1088
+ if gate_owner[gate_key] != v:
1089
+ continue
1090
+ shape = c
1091
+ break
1092
+ self.cursor_shape = shape
1093
+
1094
+ return result, result_by_type
1095
+
1096
+ def is_down(self, input_id: str) -> bool:
1097
+ s = self._states.get(input_id)
1098
+ return s.is_down if s else False
1099
+
1100
+ def cursor(self) -> tuple[float, float]:
1101
+ return (self._cursor_x, self._cursor_y)