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,1054 @@
1
+ """
2
+ Cross-platform userspace input backend using pynput.
3
+
4
+ Works on Linux (X11/Wayland), macOS, and Windows without root/admin.
5
+
6
+ Requirements:
7
+ pip install pynput
8
+
9
+ Note: pynput uses OS-level hooks:
10
+ - Linux: Xlib (X11) or uinput
11
+ - macOS: Quartz event taps
12
+ - Windows: Win32 hooks
13
+ """
14
+
15
+ from __future__ import annotations
16
+ import threading
17
+ from collections import deque
18
+ from typing import TYPE_CHECKING
19
+ import time
20
+
21
+ import meltygui.core.windowing.window_api as glfw
22
+
23
+ from meltygui.core.windowing.glfw_utils import print_stack_trace
24
+
25
+ # Bare modifiers never count as "typing" for Melody._keys_down / typing_hold.
26
+ _MODIFIER_KEYS = {glfw.KEY_LEFT_SHIFT, glfw.KEY_RIGHT_SHIFT,
27
+ glfw.KEY_LEFT_CONTROL, glfw.KEY_RIGHT_CONTROL,
28
+ glfw.KEY_LEFT_ALT, glfw.KEY_RIGHT_ALT,
29
+ glfw.KEY_LEFT_SUPER, glfw.KEY_RIGHT_SUPER,
30
+ glfw.KEY_CAPS_LOCK, glfw.KEY_NUM_LOCK}
31
+
32
+ try:
33
+ from pynput import mouse, keyboard
34
+ from pynput.keyboard import Key, KeyCode
35
+ from pynput.mouse import Button
36
+ HAS_PYNPUT = True
37
+ except ImportError:
38
+ HAS_PYNPUT = False
39
+ mouse = keyboard = Key = KeyCode = Button = None
40
+
41
+ if TYPE_CHECKING:
42
+ from meltygui.core.input.input_handler import InputHandler
43
+
44
+
45
+ # =============================================================================
46
+ # Key Mappings
47
+ # =============================================================================
48
+
49
+ def _key_to_id(key) -> str:
50
+ """Convert pynput key to input_id string."""
51
+ if not HAS_PYNPUT:
52
+ return str(key)
53
+
54
+ # Regular character keys
55
+ if isinstance(key, KeyCode):
56
+ if key.char:
57
+ return key.char.lower()
58
+ # Virtual key code fallback
59
+ if key.vk:
60
+ return f"vk_{key.vk}"
61
+
62
+ print_stack_trace()
63
+ return "unknown"
64
+
65
+ # Special keys
66
+ if isinstance(key, Key):
67
+ mapping = {
68
+ Key.space: "space",
69
+ Key.enter: "enter",
70
+ Key.tab: "tab",
71
+ Key.backspace: "backspace",
72
+ Key.esc: "escape",
73
+ Key.delete: "delete",
74
+ Key.insert: "insert",
75
+ Key.home: "home",
76
+ Key.end: "end",
77
+ Key.page_up: "page_up",
78
+ Key.page_down: "page_down",
79
+ Key.up: "up",
80
+ Key.down: "down",
81
+ Key.left: "left",
82
+ Key.right: "right",
83
+ Key.shift: "left_shift",
84
+ Key.shift_r: "right_shift",
85
+ Key.ctrl: "left_ctrl",
86
+ Key.ctrl_r: "right_ctrl",
87
+ Key.alt: "left_alt",
88
+ Key.alt_r: "right_alt",
89
+ Key.cmd: "left_meta",
90
+ Key.cmd_r: "right_meta",
91
+ Key.caps_lock: "caps_lock",
92
+ Key.num_lock: "num_lock",
93
+ Key.scroll_lock: "scroll_lock",
94
+ Key.print_screen: "print_screen",
95
+ Key.pause: "pause",
96
+ Key.menu: "menu",
97
+ }
98
+
99
+ if key in mapping:
100
+ return mapping[key]
101
+
102
+ # Function keys
103
+ name = key.name if hasattr(key, 'name') else str(key)
104
+ if name.startswith("f") and name[1:].isdigit():
105
+ return name
106
+
107
+ return name.lower().replace("key.", "")
108
+
109
+ return str(key).lower()
110
+
111
+
112
+ """
113
+ Dear ImGui input backend.
114
+
115
+ Works with pyimgui or the imgui bundle (imgui[glfw], imgui[sdl2], etc.)
116
+
117
+ Requirements:
118
+ pip install imgui[glfw] # or imgui[sdl2], pyimgui, etc.
119
+
120
+ Note: ImGui receives input from a windowing backend (GLFW, SDL2, etc.)
121
+ This backend reads ImGui's IO state and converts it to InputHandler events.
122
+ """
123
+
124
+ from typing import TYPE_CHECKING
125
+ import time
126
+
127
+ try:
128
+ import meltygui_imgui as imgui
129
+
130
+ HAS_IMGUI = True
131
+ except ImportError:
132
+ HAS_IMGUI = False
133
+ imgui = None
134
+
135
+ if TYPE_CHECKING:
136
+ from meltygui.core.input.input_handler import InputHandler
137
+
138
+
139
+
140
+
141
+ def _button_to_id(button) -> str:
142
+ """Convert pynput mouse button to input_id string."""
143
+ if not HAS_PYNPUT:
144
+ return str(button)
145
+
146
+ mapping = {
147
+ Button.left: "left_mouse",
148
+ Button.right: "right_mouse",
149
+ Button.middle: "middle_mouse",
150
+ }
151
+
152
+ if button in mapping:
153
+ return mapping[button]
154
+
155
+ # Extra buttons (Button.x1, Button.x2, etc.)
156
+ name = button.name if hasattr(button, 'name') else str(button)
157
+ return f"mouse_{name}"
158
+
159
+
160
+ # =============================================================================
161
+ # Raw Event Container
162
+ # =============================================================================
163
+
164
+ class RawEvent:
165
+ __slots__ = ('type', 'input_id', 'value', 'x', 'y', 'dx', 'dy', 't')
166
+
167
+ DOWN = 0
168
+ UP = 1
169
+ MOVE = 2
170
+ SCROLL = 3
171
+
172
+ def __init__(self, type_: int, input_id: str, value: float,
173
+ x: float, y: float, dx: float, dy: float, t: float):
174
+ self.type = type_
175
+ self.input_id = input_id
176
+ self.value = value
177
+ self.x = x
178
+ self.y = y
179
+ self.dx = dx
180
+ self.dy = dy
181
+ self.t = t
182
+
183
+
184
+ # =============================================================================
185
+ # ImGui Backend (State-Based)
186
+ # =============================================================================
187
+
188
+ class ImGuiBackend:
189
+ """
190
+ Backend that reads Dear ImGui's IO state.
191
+
192
+ ImGui maintains input state rather than emitting events, so this backend
193
+ tracks state changes between frames and generates corresponding events.
194
+
195
+ Usage:
196
+ handler = InputHandler()
197
+ backend = ImGuiBackend(handler)
198
+
199
+ while running:
200
+ imgui.new_frame() # ImGui updates IO state here
201
+
202
+ handler.begin_frame()
203
+ handler.register_hovered(...)
204
+
205
+ backend.pump() # Read IO state, generate events
206
+
207
+ events = handler.process_frame()
208
+
209
+ # ... render imgui ...
210
+ imgui.render()
211
+ """
212
+
213
+ # ImGui mouse button indices
214
+ MOUSE_BUTTONS = {
215
+ 0: "left_mouse",
216
+ 1: "right_mouse",
217
+ 2: "middle_mouse",
218
+ 3: "mouse_4",
219
+ 4: "mouse_5",
220
+ }
221
+
222
+ # ImGui key indices (imgui.KEY_*)
223
+ # This mapping covers common keys; extend as needed
224
+ KEY_NAMES = {
225
+ glfw.KEY_TAB: "tab",
226
+ glfw.KEY_LEFT: "left",
227
+ glfw.KEY_RIGHT: "right",
228
+ glfw.KEY_UP: "up",
229
+ glfw.KEY_DOWN: "down",
230
+ glfw.KEY_PAGE_UP: "page_up",
231
+ glfw.KEY_PAGE_DOWN: "page_down",
232
+ glfw.KEY_HOME: "home",
233
+ glfw.KEY_END: "end",
234
+ glfw.KEY_INSERT: "insert",
235
+ glfw.KEY_DELETE: "delete",
236
+ glfw.KEY_BACKSPACE: "backspace",
237
+ glfw.KEY_SPACE: "space",
238
+ glfw.KEY_ENTER: "enter",
239
+ glfw.KEY_ESCAPE: "escape",
240
+ glfw.KEY_A: "a",
241
+ glfw.KEY_B: "b",
242
+ glfw.KEY_C: "c",
243
+ glfw.KEY_V: "v",
244
+ glfw.KEY_F: "f",
245
+ glfw.KEY_X: "x",
246
+ glfw.KEY_Y: "y",
247
+ glfw.KEY_S: "s",
248
+ glfw.KEY_Z: "z",
249
+ glfw.KEY_SLASH: "slash",
250
+ # -/= - draw_text's scope-fold shortcuts (Ctrl[+Shift]+Minus/Equal)
251
+ # subscribe to these; Ctrl+Equal doubles as "Ctrl+Plus" (plus IS
252
+ # shift+equal, so the shifted variant arrives as ctrl_shift_equal).
253
+ glfw.KEY_MINUS: "minus",
254
+ glfw.KEY_EQUAL: "equal",
255
+ # Arrows - navigation undo/redo (Ctrl+Shift+Left/Right) subscribes to
256
+ # these; the text editor keeps reading its arrows from frame_key_events.
257
+ glfw.KEY_LEFT: "left_arrow",
258
+ glfw.KEY_RIGHT: "right_arrow",
259
+ # Main-row digits - Ctrl+Shift+3 (region screenshot) subscribes via
260
+ # on_action("ctrl_shift_3")"); numeric keys feed as "key_<code>".
261
+ glfw.KEY_0: "0", glfw.KEY_1: "1", glfw.KEY_2: "2", glfw.KEY_3: "3",
262
+ glfw.KEY_4: "4", glfw.KEY_5: "5", glfw.KEY_6: "6", glfw.KEY_7: "7",
263
+ glfw.KEY_8: "8", glfw.KEY_9: "9",
264
+ # Numpad - Blender-style viewport hotkeys (voxel views) live on these.
265
+ glfw.KEY_KP_0: "kp_0",
266
+ glfw.KEY_KP_1: "kp_1",
267
+ glfw.KEY_KP_2: "kp_2",
268
+ glfw.KEY_KP_3: "kp_3",
269
+ glfw.KEY_KP_4: "kp_4",
270
+ glfw.KEY_KP_5: "kp_5",
271
+ glfw.KEY_KP_6: "kp_6",
272
+ glfw.KEY_KP_7: "kp_7",
273
+ glfw.KEY_KP_8: "kp_8",
274
+ glfw.KEY_KP_9: "kp_9",
275
+ glfw.KEY_KP_DECIMAL: "kp_decimal",
276
+ glfw.KEY_KP_DIVIDE: "kp_divide",
277
+ glfw.KEY_KP_MULTIPLY: "kp_multiply",
278
+ glfw.KEY_KP_SUBTRACT: "kp_subtract",
279
+ glfw.KEY_KP_ADD: "kp_add",
280
+ glfw.KEY_KP_ENTER: "kp_enter",
281
+ } if HAS_IMGUI else {}
282
+
283
+ def __init__(self, handler: InputHandler):
284
+ if not HAS_IMGUI:
285
+ raise ImportError("pip install imgui[glfw] # or imgui[sdl2], pyimgui")
286
+
287
+ self.handler = handler
288
+
289
+ # Previous frame state for change detection
290
+ self._prev_mouse_pos = (0.0, 0.0)
291
+ self._prev_mouse_down = [False] * 5
292
+ self._prev_keys_down: set[int] = set()
293
+ self._prev_scroll = (0.0, 0.0)
294
+
295
+ def pump(self):
296
+
297
+ """
298
+ Read ImGui IO state and generate events for state changes.
299
+ Call once per frame after imgui.new_frame().
300
+ """
301
+ io = imgui.get_io()
302
+ t = time.perf_counter()
303
+
304
+ # --- Mouse Position ---
305
+ mx, my = io.mouse_pos
306
+ px, py = self._prev_mouse_pos
307
+
308
+ if (mx, my) != (px, py) and mx >= 0 and my >= 0:
309
+ dx, dy = mx - px, my - py
310
+ self.handler.feed_move(mx, my, dx, dy, t)
311
+
312
+ self._prev_mouse_pos = (mx, my)
313
+
314
+ # --- Mouse Buttons ---
315
+ for i in range(5):
316
+ input_id = self.MOUSE_BUTTONS.get(i, f"mouse_{i}")
317
+ is_down = io.mouse_down[i]
318
+ was_down = self._prev_mouse_down[i]
319
+
320
+ if is_down and not was_down:
321
+ self.handler.feed_down(input_id, mx, my, t)
322
+ elif not is_down and was_down:
323
+ self.handler.feed_up(input_id, mx, my, t)
324
+
325
+ self._prev_mouse_down[i] = is_down
326
+
327
+ # --- Mouse Wheel ---
328
+ wheel_x = io.mouse_wheel_horizontal
329
+ wheel_y = io.mouse_wheel
330
+
331
+ if wheel_y != 0:
332
+ self.handler.feed_change("scroll_y", wheel_y, t)
333
+ if wheel_x != 0:
334
+ self.handler.feed_change("scroll_x", wheel_x, t)
335
+
336
+ # --- Keyboard ---
337
+ current_keys: set[int] = set()
338
+
339
+ for key_index, key_name in self.KEY_NAMES.items():
340
+ if io.keys_down[key_index]:
341
+ current_keys.add(key_index)
342
+
343
+ # Keys pressed this frame
344
+ for key_index in current_keys - self._prev_keys_down:
345
+ key_name = self.KEY_NAMES.get(key_index, f"key_{key_index}")
346
+ self.handler.feed_down(key_name, mx, my, t)
347
+
348
+ # Keys released this frame
349
+ for key_index in self._prev_keys_down - current_keys:
350
+ key_name = self.KEY_NAMES.get(key_index, f"key_{key_index}")
351
+ self.handler.feed_up(key_name, mx, my, t)
352
+
353
+ self._prev_keys_down = current_keys
354
+
355
+ # --- Modifiers ---
356
+ self.handler.set_modifiers(
357
+ shift=io.key_shift,
358
+ ctrl=io.key_ctrl,
359
+ alt=io.key_alt,
360
+ meta=io.key_super,
361
+ )
362
+
363
+ @property
364
+ def allow_hovering(self) -> bool:
365
+ """True if ImGui allows hovering (not blocking input)."""
366
+ return not imgui.is_window_hovered()
367
+
368
+
369
+ # =============================================================================
370
+ # GLFW Callback Backend (event-queued, frame-rate independent)
371
+ # =============================================================================
372
+
373
+ # input_id -> GLFW button index (ImGuiBackend.MOUSE_BUTTONS inverted).
374
+ _BUTTON_INDEX = {name: i for i, name in ImGuiBackend.MOUSE_BUTTONS.items()}
375
+
376
+
377
+ class GlfwQueueBackend:
378
+ """Feeds the InputHandler from GLFW callbacks instead of sampling state once
379
+ per frame.
380
+
381
+ GLFW callbacks fire for *every* event during glfw.poll_events/wait_events,
382
+ even when rendering is slow, so nothing is dropped between frames — fixing
383
+ missed keystrokes, hard-to-click buttons, and skipped drags under load.
384
+
385
+ Callbacks are chained: any previously-registered callback (the imgui
386
+ GlfwRenderer's) is preserved and still called, so imgui keeps working.
387
+
388
+ Keyboard down/up and mouse buttons/scroll are fed to the handler queue
389
+ (consumed via on_action). Raw key presses/repeats are also recorded on
390
+ Melty.frame_key_events so the text editor can drain them in order rather
391
+ than polling imgui.is_key_pressed (which only sees the current frame).
392
+
393
+ Cursor position and modifiers stay sampled in pump() — they're level state,
394
+ so per-frame sampling loses nothing and avoids queue churn from raw motion.
395
+ """
396
+
397
+ def __init__(self, handler, window):
398
+ if not HAS_IMGUI:
399
+ raise ImportError("pip install imgui[glfw]")
400
+ self.handler = handler
401
+ self.window = window
402
+ self._prev_mouse_pos = (0.0, 0.0)
403
+ # Register our callbacks, chaining whatever was registered pre
404
+ # (imgui's GlfwRenderer). glfw.set_*_callback returns the prior callback.
405
+ self._prev_key = glfw.set_key_callback(window, self._on_key)
406
+ self._prev_char = glfw.set_char_callback(window, self._on_char)
407
+ self._prev_button = glfw.set_mouse_button_callback(window, self._on_button)
408
+ self._prev_scroll = glfw.set_scroll_callback(window, self._on_scroll)
409
+ # Mouse motion (for hover/drag) - only used to defer the background parse
410
+ # while the mouse is busy; chains to whatever imgui registered (if any).
411
+ self._prev_cursor = glfw.set_cursor_pos_callback(window, self._on_move)
412
+ # Pointer inside / outside our window - the GC backend's "is anybody
413
+ # reading?" bit (an unfocused window with the pointer over it is being
414
+ # read, not left).
415
+ self._prev_enter = glfw.set_cursor_enter_callback(window, self._on_enter)
416
+ # The handler asks this before emitting HELD/DRAGGED each frame.
417
+ from meltygui.core.input.input_handler import set_button_probe
418
+ set_button_probe(self.button_really_down)
419
+
420
+ def button_really_down(self, input_id):
421
+ """The REAL level state of a mouse button, or None for anything that
422
+ isn't one. Three sources, any of which saying UP wins — after a
423
+ freeze every single one can be stale on its own:
424
+ - glfw.get_mouse_button: fed by the same event stream as our
425
+ callbacks, so it misses the same lost release;
426
+ - wayland_move.button_masked: a compositor grab (xdg move/resize)
427
+ swallowed the release and GLFW still reads PRESS;
428
+ - wayland_move's own wl_pointer `held` set: cleared by the release
429
+ AND by a pointer `leave` while buttons are held (the compositor
430
+ broke the implicit grab — the release goes elsewhere, the freeze
431
+ case), which GLFW never reflects in its button state."""
432
+ button = _BUTTON_INDEX.get(input_id)
433
+ if button is None:
434
+ return None
435
+ try:
436
+ if glfw.get_mouse_button(self.window, button) == glfw.RELEASE:
437
+ return False
438
+ except Exception:
439
+ pass
440
+ try:
441
+ import meltygui.core.windowing.wayland_move as wayland_move
442
+ if wayland_move.button_masked(button):
443
+ return False
444
+ held = wayland_move.button_held(button)
445
+ if held is False:
446
+ return False
447
+ except Exception:
448
+ pass
449
+ return True
450
+
451
+ @staticmethod
452
+ def _content_cursor(window):
453
+ """GLFW's cursor in imgui/content coordinates: the frameless
454
+ window's shadow margin (Melty.frame_inset) sits outside imgui's
455
+ display, so the handler's press positions must shift like io.mouse_pos."""
456
+ x, y = glfw.get_cursor_pos(window)
457
+ try:
458
+ from meltygui.core.melty import Melty
459
+ ox, oy = getattr(Melty, "frame_origin", None) or (0, 0)
460
+ except Exception:
461
+ ox, oy = 0, 0
462
+ return x - ox, y - oy
463
+
464
+ @staticmethod
465
+ def _chain(prev, *args):
466
+ if prev is not None:
467
+ try:
468
+ prev(*args)
469
+ except Exception:
470
+ pass
471
+
472
+ @staticmethod
473
+ def _set_mods(handler, mods):
474
+ handler.set_modifiers(
475
+ shift=bool(mods & glfw.MOD_SHIFT), ctrl=bool(mods & glfw.MOD_CONTROL),
476
+ alt=bool(mods & glfw.MOD_ALT), meta=bool(mods & glfw.MOD_SUPER))
477
+
478
+ @staticmethod
479
+ def _stamp_input():
480
+ # Recency signal for the cst→dict parse's cooperative UI yield: key (incl.
481
+ # held-key auto-repeat), mouse button, DRAG (held button), or scroll defers
482
+ # the background parse so the render thread stays smooth. Bare hover does
483
+ # NOT (it's cheap and would stall the parse indefinitely - see _on_move).
484
+ try:
485
+ from meltygui.core.melty import Melty
486
+ Melty._last_input_time = time.monotonic()
487
+ except Exception:
488
+ pass
489
+
490
+ def _on_key(self, window, key, scancode, action, mods):
491
+ try:
492
+ from meltygui.core.melty import Melty
493
+ from meltygui.core.windowing.glfw_utils import request_render
494
+ self._stamp_input() # key activity (press/repeat/release) defers the parse
495
+ self._set_mods(self.handler, mods)
496
+ x, y = self._content_cursor(window)
497
+ name = ImGuiBackend.KEY_NAMES.get(key, f"key_{key}")
498
+ if action == glfw.PRESS or action == glfw.REPEAT:
499
+ # Orchestrator funnel: records the keystroke for a replayable
500
+ # take, or consumes it while a replay is driving (Esc aborts
501
+ # there). Consumed = imgui never sees it either (chain skipped).
502
+ from meltygui.core.input.input_handler import input_tap
503
+ if input_tap("key", key, mods):
504
+ return
505
+ # Ordered record for the text editor (preserves typed order, and
506
+ # repeats so a held key still inserts/navigates).
507
+ Melty.frame_key_events.append((key, mods))
508
+ # Key-only recency stamp (mouse doesn't touch this) - read by
509
+ # RenderHost.typing_hold to defer host draws while typing.
510
+ Melty._last_key_time = time.monotonic()
511
+ if action == glfw.PRESS:
512
+ # Held-key set for typing_hold: repeats don't arrive
513
+ # reliably (Wayland gaps, OS repeat delay), so the hold
514
+ # rides on this press until RELEASE. Bare modifiers stay out
515
+ # - holding Ctrl while focused isn't typing and would
516
+ # starve host draws indefinitely.
517
+ if key not in _MODIFIER_KEYS:
518
+ Melty._keys_down.add(key)
519
+ self.handler.feed_down(name, x, y)
520
+ request_render()
521
+ elif action == glfw.RELEASE:
522
+ Melty._keys_down.discard(key)
523
+ # The typing-debounce tail starts at release, not the last
524
+ # press - a long hold shouldn't end with an already-expired
525
+ # window.
526
+ Melty._last_key_time = time.monotonic()
527
+ self.handler.feed_up(name, x, y)
528
+ request_render()
529
+ except Exception:
530
+ pass
531
+ self._chain(self._prev_key, window, key, scancode, action, mods)
532
+
533
+ def _on_char(self, window, codepoint):
534
+ # imgui consumes this for its own text editor; the app's text input
535
+ # uses frame_key_events. Just chain so imgui still gets the character.
536
+ # The Orchestrator tap records the char for replay (injected back via
537
+ # io.add_input_character) and mutes real typing while replaying.
538
+ from meltygui.core.input.input_handler import input_tap
539
+ if input_tap("char", codepoint):
540
+ return
541
+ self._chain(self._prev_char, window, codepoint)
542
+
543
+ def _on_button(self, window, button, action, mods):
544
+ try:
545
+ from meltygui.core.windowing.glfw_utils import request_render
546
+ self._stamp_input() # mouse button press/release defers the parse
547
+ self._set_mods(self.handler, mods)
548
+ x, y = self._content_cursor(window)
549
+ name = ImGuiBackend.MOUSE_BUTTONS.get(button, f"mouse_{button}")
550
+ if action == glfw.PRESS:
551
+ self.handler.feed_down(name, x, y)
552
+ elif action == glfw.RELEASE:
553
+ self.handler.feed_up(name, x, y)
554
+ request_render()
555
+ except Exception:
556
+ pass
557
+ self._chain(self._prev_button, window, button, action, mods)
558
+
559
+ def _on_scroll(self, window, x_offset, y_offset):
560
+ try:
561
+ from meltygui.core.windowing.glfw_utils import request_render
562
+ self._stamp_input() # scrolling defers the parse
563
+ if y_offset:
564
+ self.handler.feed_change("scroll_y", y_offset)
565
+ if x_offset:
566
+ self.handler.feed_change("scroll_x", x_offset)
567
+ request_render()
568
+ except Exception:
569
+ pass
570
+ self._chain(self._prev_scroll, window, x_offset, y_offset)
571
+
572
+ def _on_move(self, window, x, y):
573
+ # Mouse motion. Bare HOVER does NOT defer the cst→dict parse; it's cheap to
574
+ # render, and previously stamping on every move kept the parse from ever
575
+ # resuming (e.g. moving the cursor over a freshly-loaded page stalled it
576
+ # indefinitely, the slow initial load). A DRAG still defers via its held
577
+ # mouse button (pump()'s _any_input_held), so no stamp is needed here.
578
+ # Fires often, so keep it minimal and chain imgui's own cursor callback.
579
+ # It DOES stamp presence for the GC watchdog (gc_manager.tick): a
580
+ # return to the studio starts with the pointer crossing the window.
581
+ # The loop renders only on request (LSDStudio's render loop gates
582
+ # on _needs_render - a wait_events wake alone is no frame), so a
583
+ # new move here triggers the frame that answers it.
584
+ try:
585
+ from meltygui.core.melty import Melty
586
+ from meltygui.core.windowing.glfw_utils import request_render
587
+ Melty._last_presence_time = time.monotonic()
588
+ request_render()
589
+ except Exception:
590
+ pass
591
+ self._chain(getattr(self, "_prev_cursor", None), window, x, y)
592
+
593
+ def _on_enter(self, window, entered):
594
+ try:
595
+ from meltygui.core.melty import Melty
596
+ from meltygui.core.windowing.glfw_utils import request_render
597
+ Melty._pointer_inside = bool(entered)
598
+ if entered:
599
+ Melty._last_presence_time = time.monotonic()
600
+ request_render() # hover state changes either way (see _on_move)
601
+ except Exception:
602
+ pass
603
+ self._chain(getattr(self, "_prev_enter", None), window, entered)
604
+
605
+ def pump(self):
606
+ """Per-frame: feed the latest cursor position (level state), refresh
607
+ modifiers, and refresh the cst→dict cooperative-yield recency signal
608
+ while any input is HELD.
609
+
610
+ The GLFW edge callbacks (PRESS/REPEAT/RELEASE) don't fire every frame —
611
+ GLFW REPEAT is sparse or absent (e.g. on Wayland), and the editor's
612
+ held-key repeat is driven by imgui's own auto-repeat — so stamping only in
613
+ _on_key let _last_input_time go stale mid-hold and the parse stopped
614
+ yielding. imgui's io.keys_down / mouse_down ARE per-frame level state, so
615
+ stamp from them here every frame the input is held. pump() is re-resolved
616
+ each frame, so this path also hotswaps without a restart."""
617
+ io = imgui.get_io()
618
+ mx, my = io.mouse_pos
619
+ px, py = self._prev_mouse_pos
620
+ moved = (mx, my) != (px, py) and mx >= 0 and my >= 0
621
+ if moved:
622
+ self.handler.feed_move(mx, my, mx - px, my - py)
623
+ self._prev_mouse_pos = (mx, my)
624
+ # Missed-release guard: the handler's drag capture latches on is_down,
625
+ # and is_down only clears via feed_up. See button_really_down for the
626
+ # sources of truth; the handler's own process_frame runs the same
627
+ # probe (button_probe) right before it emits HELD/DRAGGED, so a
628
+ # phantom drag can't outlive the frame the button is seen up.
629
+ for i, name in ImGuiBackend.MOUSE_BUTTONS.items():
630
+ if self.handler.is_down(name) and self.button_really_down(name) is False:
631
+ # io.mouse_pos is -FLT_MAX with the cursor off-window; fall
632
+ # back to the handler's last known position.
633
+ ux, uy = (mx, my) if mx >= 0 and my >= 0 else self.handler.cursor()
634
+ self.handler.feed_up(name, ux, uy)
635
+ self.handler.set_modifiers(
636
+ shift=io.key_shift, ctrl=io.key_ctrl, alt=io.key_alt, meta=io.key_super)
637
+ # Only a HELD key/button (typing or a drag) defers the parse; NOT bare
638
+ # hover (`moved` alone), which would stall the parse continuously while the
639
+ # cursor wanders (the slow initial load). Scroll/click/keypress edges still
640
+ # stamp via their own callbacks.
641
+ if self._any_input_held(io):
642
+ self._stamp_input()
643
+
644
+ @staticmethod
645
+ def _any_input_held(io):
646
+ """True if any key or mouse button is currently down (imgui per-frame
647
+ level state) — so a held key/button keeps deferring the parse each frame,
648
+ regardless of how often GLFW delivers REPEAT edges."""
649
+ if io.key_ctrl or io.key_shift or io.key_alt or io.key_super:
650
+ return True
651
+ kd = io.keys_down
652
+ for i in range(len(kd)):
653
+ if kd[i]:
654
+ return True
655
+ for i in range(5):
656
+ if imgui.is_mouse_down(i):
657
+ return True
658
+ return False
659
+
660
+ @property
661
+ def allow_hovering(self) -> bool:
662
+ return not imgui.is_window_hovered()
663
+
664
+
665
+ # =============================================================================
666
+ # Pynput Backend
667
+ # =============================================================================
668
+
669
+ class PynputBackend:
670
+ """
671
+ Cross-platform userspace input backend.
672
+
673
+ Usage:
674
+ handler = InputHandler()
675
+ backend = PynputBackend(handler)
676
+ backend.start()
677
+
678
+ while running:
679
+ handler.begin_frame()
680
+ handler.register_hovered(...)
681
+ backend.pump()
682
+ events = handler.process_frame()
683
+
684
+ backend.stop()
685
+ """
686
+
687
+ def __init__(self, handler: InputHandler):
688
+ if not HAS_PYNPUT:
689
+ raise ImportError("pip install pynput")
690
+
691
+ self.handler = handler
692
+ self._queue: deque[RawEvent] = deque(maxlen=1024)
693
+ self._lock = threading.Lock()
694
+
695
+ self._cursor_x = 0.0
696
+ self._cursor_y = 0.0
697
+
698
+ self._shift = False
699
+ self._ctrl = False
700
+ self._alt = False
701
+ self._meta = False
702
+
703
+ self._mouse_listener: mouse.Listener | None = None
704
+ self._keyboard_listener: keyboard.Listener | None = None
705
+
706
+ def start(self):
707
+ """Start listening for input events."""
708
+ self._mouse_listener = mouse.Listener(
709
+ on_move=self._on_move,
710
+ on_click=self._on_click,
711
+ on_scroll=self._on_scroll,
712
+ )
713
+ self._keyboard_listener = keyboard.Listener(
714
+ on_press=self._on_press,
715
+ on_release=self._on_release,
716
+ )
717
+
718
+ self._mouse_listener.start()
719
+ self._keyboard_listener.start()
720
+
721
+ def stop(self):
722
+ """Stop listening."""
723
+ if self._mouse_listener:
724
+ self._mouse_listener.stop()
725
+ self._mouse_listener = None
726
+ if self._keyboard_listener:
727
+ self._keyboard_listener.stop()
728
+ self._keyboard_listener = None
729
+
730
+ def pump(self):
731
+ """Transfer accumulated events to handler. Call once per frame."""
732
+ with self._lock:
733
+ events = list(self._queue)
734
+ self._queue.clear()
735
+ self.handler.set_modifiers(self._shift, self._ctrl, self._alt, self._meta)
736
+
737
+ # Process events in order to preserve drag semantics
738
+ for e in events:
739
+ if e.type == RawEvent.DOWN:
740
+ self.handler.feed_down(e.input_id, e.x, e.y, e.t)
741
+ elif e.type == RawEvent.UP:
742
+ self.handler.feed_up(e.input_id, e.x, e.y, e.t)
743
+ elif e.type == RawEvent.MOVE:
744
+ self.handler.feed_move(e.x, e.y, e.dx, e.dy, e.t)
745
+ elif e.type == RawEvent.SCROLL:
746
+ self.handler.feed_change(e.input_id, e.value, e.t)
747
+
748
+ # -------------------------------------------------------------------------
749
+ # Mouse callbacks (run in pynput's thread)
750
+ # -------------------------------------------------------------------------
751
+
752
+ def _on_move(self, x: int, y: int):
753
+ t = time.perf_counter()
754
+ with self._lock:
755
+ dx = x - self._cursor_x
756
+ dy = y - self._cursor_y
757
+ self._cursor_x = float(x)
758
+ self._cursor_y = float(y)
759
+ self._queue.append(RawEvent(
760
+ RawEvent.MOVE, "cursor", 0.0, x, y, dx, dy, t
761
+ ))
762
+
763
+ def _on_click(self, x: int, y: int, button, pressed: bool):
764
+ t = time.perf_counter()
765
+ input_id = _button_to_id(button)
766
+ with self._lock:
767
+ self._cursor_x = float(x)
768
+ self._cursor_y = float(y)
769
+
770
+ if pressed:
771
+ self._queue.append(RawEvent(
772
+ RawEvent.DOWN, input_id, 1.0, x, y, 0, 0, t
773
+ ))
774
+ else:
775
+ self._queue.append(RawEvent(
776
+ RawEvent.UP, input_id, 0.0, x, y, 0, 0, t
777
+ ))
778
+
779
+ def _on_scroll(self, x: int, y: int, dx: int, dy: int):
780
+ t = time.perf_counter()
781
+ with self._lock:
782
+ self._cursor_x = float(x)
783
+ self._cursor_y = float(y)
784
+
785
+ if dy != 0:
786
+ self._queue.append(RawEvent(
787
+ RawEvent.SCROLL, "scroll_y", float(dy), x, y, 0, 0, t
788
+ ))
789
+ if dx != 0:
790
+ self._queue.append(RawEvent(
791
+ RawEvent.SCROLL, "scroll_x", float(dx), x, y, 0, 0, t
792
+ ))
793
+
794
+ # -------------------------------------------------------------------------
795
+ # Keyboard callbacks (run in pynput's thread)
796
+ # -------------------------------------------------------------------------
797
+
798
+ def _on_press(self, key):
799
+ t = time.perf_counter()
800
+ input_id = _key_to_id(key)
801
+ self._update_modifiers(input_id, True)
802
+
803
+ with self._lock:
804
+ self._queue.append(RawEvent(
805
+ RawEvent.DOWN, input_id, 1.0,
806
+ self._cursor_x, self._cursor_y, 0, 0, t
807
+ ))
808
+
809
+ def _on_release(self, key):
810
+ t = time.perf_counter()
811
+ input_id = _key_to_id(key)
812
+ self._update_modifiers(input_id, False)
813
+
814
+ with self._lock:
815
+ self._queue.append(RawEvent(
816
+ RawEvent.UP, input_id, 0.0,
817
+ self._cursor_x, self._cursor_y, 0, 0, t
818
+ ))
819
+
820
+ def _update_modifiers(self, input_id: str, pressed: bool):
821
+ if "shift" in input_id:
822
+ self._shift = pressed
823
+ elif "ctrl" in input_id:
824
+ self._ctrl = pressed
825
+ elif "alt" in input_id:
826
+ self._alt = pressed
827
+ elif "meta" in input_id:
828
+ self._meta = pressed
829
+
830
+ def set_cursor(self, x: float, y: float):
831
+ """Manually set cursor position if needed."""
832
+ with self._lock:
833
+ self._cursor_x = x
834
+ self._cursor_y = y
835
+
836
+ @property
837
+ def allow_hovering(self) -> bool:
838
+ """True if ImGui allows hovering (not blocking input)."""
839
+ return True
840
+
841
+
842
+ # =============================================================================
843
+ # Window-Integrated Backend (for toolkit integration)
844
+ # =============================================================================
845
+
846
+ class JsonBackend:
847
+ """
848
+ Backend that receives events as JSON/dict.
849
+
850
+ Use this to integrate with any windowing toolkit (pygame, glfw, sdl, tkinter, etc.)
851
+ by converting their events to a simple dict format.
852
+
853
+ Usage:
854
+ handler = InputHandler()
855
+ backend = JsonBackend(handler)
856
+
857
+ # In your toolkit's event loop:
858
+ for event in toolkit_events:
859
+ backend.push({
860
+ "type": "down", # or "up", "move", "scroll"
861
+ "input": "left_mouse", # or key name
862
+ "x": event.x,
863
+ "y": event.y,
864
+ })
865
+
866
+ backend.pump()
867
+ events = handler.process_frame()
868
+ """
869
+
870
+ def __init__(self, handler: InputHandler):
871
+ self.handler = handler
872
+ self._queue: deque[dict] = deque(maxlen=1024)
873
+ self._cursor_x = 0.0
874
+ self._cursor_y = 0.0
875
+
876
+ def push(self, event: dict):
877
+ """
878
+ Push an event dict:
879
+ {"type": "down"|"up"|"move"|"scroll", "input": str, "x": float, "y": float, "value": float}
880
+ """
881
+ self._queue.append(event)
882
+
883
+ def push_down(self, input_id: str, x: float = None, y: float = None):
884
+ self._queue.append({"type": "down", "input": input_id,
885
+ "x": x if x is not None else self._cursor_x,
886
+ "y": y if y is not None else self._cursor_y})
887
+
888
+ def push_up(self, input_id: str, x: float = None, y: float = None):
889
+ self._queue.append({"type": "up", "input": input_id,
890
+ "x": x if x is not None else self._cursor_x,
891
+ "y": y if y is not None else self._cursor_y})
892
+
893
+ def push_move(self, x: float, y: float, dx: float = None, dy: float = None):
894
+ if dx is None:
895
+ dx = x - self._cursor_x
896
+ if dy is None:
897
+ dy = y - self._cursor_y
898
+ self._cursor_x = x
899
+ self._cursor_y = y
900
+ # Queue move events to preserve ordering with button events
901
+ self._queue.append({"type": "move", "x": x, "y": y, "dx": dx, "dy": dy})
902
+
903
+ def push_scroll(self, dx: float = 0, dy: float = 0):
904
+ if dy:
905
+ self._queue.append({"type": "scroll", "input": "scroll_y", "value": dy})
906
+ if dx:
907
+ self._queue.append({"type": "scroll", "input": "scroll_x", "value": dx})
908
+
909
+ def pump(self):
910
+ """Transfer events to handler in order."""
911
+ while self._queue:
912
+ e = self._queue.popleft()
913
+ t = e.get("t") or e.get("timestamp")
914
+
915
+ if e["type"] == "down":
916
+ self.handler.feed_down(e["input"], e.get("x", self._cursor_x),
917
+ e.get("y", self._cursor_y), t)
918
+ elif e["type"] == "up":
919
+ self.handler.feed_up(e["input"], e.get("x", self._cursor_x),
920
+ e.get("y", self._cursor_y), t)
921
+ elif e["type"] == "move":
922
+ x, y = e["x"], e["y"]
923
+ dx, dy = e.get("dx", 0), e.get("dy", 0)
924
+ self._cursor_x, self._cursor_y = x, y
925
+ self.handler.feed_move(x, y, dx, dy, t)
926
+ elif e["type"] == "scroll":
927
+ self.handler.feed_change(e["input"], e.get("value", 0), t)
928
+
929
+ @property
930
+ def allow_hovering(self) -> bool:
931
+ """True if ImGui allows hovering (not blocking input)."""
932
+ return True
933
+
934
+ #
935
+ # # =============================================================================
936
+ # # Pygame Integration Example
937
+ # # =============================================================================
938
+ #
939
+ # class PygameBackend:
940
+ # """
941
+ # Backend for pygame.
942
+ #
943
+ # Usage:
944
+ # handler = InputHandler()
945
+ # backend = PygameBackend(handler)
946
+ #
947
+ # while running:
948
+ # handler.begin_frame()
949
+ # handler.register_hovered(...)
950
+ #
951
+ # for event in pygame.event.get():
952
+ # backend.handle(event)
953
+ #
954
+ # backend.pump()
955
+ # events = handler.process_frame()
956
+ # """
957
+ #
958
+ # def __init__(self, handler: InputHandler):
959
+ # self.handler = handler
960
+ # self._json = JsonBackend(handler)
961
+ #
962
+ # # Pygame button mapping
963
+ # self._buttons = {
964
+ # 1: "left_mouse",
965
+ # 2: "middle_mouse",
966
+ # 3: "right_mouse",
967
+ # 4: "mouse_4",
968
+ # 5: "mouse_5",
969
+ # }
970
+ #
971
+ # def handle(self, event):
972
+ # """Handle a pygame event."""
973
+ # try:
974
+ # import pygame
975
+ # except ImportError:
976
+ # return
977
+ #
978
+ # if event.type == pygame.MOUSEMOTION:
979
+ # self._json.push_move(event.pos[0], event.pos[1],
980
+ # event.rel[0] if hasattr(event, 'rel') else None,
981
+ # event.rel[1] if hasattr(event, 'rel') else None)
982
+ #
983
+ # elif event.type == pygame.MOUSEBUTTONDOWN:
984
+ # input_id = self._buttons.get(event.button, f"mouse_{event.button}")
985
+ # self._json.push_down(input_id, event.pos[0], event.pos[1])
986
+ #
987
+ # elif event.type == pygame.MOUSEBUTTONUP:
988
+ # input_id = self._buttons.get(event.button, f"mouse_{event.button}")
989
+ # self._json.push_up(input_id, event.pos[0], event.pos[1])
990
+ #
991
+ # elif event.type == pygame.MOUSEWHEEL:
992
+ # self._json.push_scroll(event.x, event.y)
993
+ #
994
+ # elif event.type == pygame.KEYDOWN:
995
+ # input_id = self._key_name(event)
996
+ # self._json.push_down(input_id)
997
+ # self._update_mods(event)
998
+ #
999
+ # elif event.type == pygame.KEYUP:
1000
+ # input_id = self._key_name(event)
1001
+ # self._json.push_up(input_id)
1002
+ # self._update_mods(event)
1003
+ #
1004
+ # def _key_name(self, event) -> str:
1005
+ #
1006
+ # # Check for pygame
1007
+ # import pygame
1008
+ # name = pygame.key.name(event.key)
1009
+ #
1010
+ # # Single letters
1011
+ # if len(name) == 1:
1012
+ # return name.lower()
1013
+ #
1014
+ # # Normalize common names
1015
+ # mapping = {
1016
+ # "space": "space",
1017
+ # "return": "enter",
1018
+ # "escape": "escape",
1019
+ # "tab": "tab",
1020
+ # "backspace": "backspace",
1021
+ # "delete": "delete",
1022
+ # "left shift": "left_shift",
1023
+ # "right shift": "right_shift",
1024
+ # "left ctrl": "left_ctrl",
1025
+ # "right ctrl": "right_ctrl",
1026
+ # "left alt": "left_alt",
1027
+ # "right alt": "right_alt",
1028
+ # "left meta": "left_meta",
1029
+ # "right meta": "right_meta",
1030
+ # }
1031
+ #
1032
+ # return mapping.get(name, name.lower().replace(" ", "_"))
1033
+ #
1034
+ # def _update_mods(self, event):
1035
+ # import pygame
1036
+ # mods = pygame.key.get_mods()
1037
+ # self.handler.set_modifiers(
1038
+ # shift=bool(mods & pygame.KMOD_SHIFT),
1039
+ # ctrl=bool(mods & pygame.KMOD_CTRL),
1040
+ # alt=bool(mods & pygame.KMOD_ALT),
1041
+ # meta=bool(mods & pygame.KMOD_META),
1042
+ # )
1043
+ #
1044
+ # def pump(self):
1045
+ # """Transfer events to handler."""
1046
+ # self._json.pump()
1047
+ #
1048
+ # @property
1049
+ # def allow_hovering(self) -> bool:
1050
+ # """True if ImGui allows hovering (not blocking input)."""
1051
+ # return True
1052
+ #
1053
+
1054
+