meltygui 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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)
|