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,1525 @@
|
|
|
1
|
+
"""Universal dict drag-and-drop for Melty collections.
|
|
2
|
+
|
|
3
|
+
Driven by draw_collection: any @render_func rendered as an item of a
|
|
4
|
+
draw_collection can be picked up by its header and dropped into any slot —
|
|
5
|
+
the gaps between items, plus the top and bottom — of any draw_collection on
|
|
6
|
+
screen, including a different one (items move between dicts).
|
|
7
|
+
|
|
8
|
+
How a drag flows, end to end:
|
|
9
|
+
|
|
10
|
+
* core_render's wrapper calls DragDrop.register_item for every view each
|
|
11
|
+
frame. Items that qualify (child of a dict/list collection, has a header)
|
|
12
|
+
subscribe their header rect to "left_mouse_drag"; the input handler
|
|
13
|
+
captures the gesture on mouse-down and keeps delivering drag events to
|
|
14
|
+
that view id for the whole drag, whether or not the view re-renders.
|
|
15
|
+
|
|
16
|
+
* DragDrop.frame_update (called from Melty.end_frame) watches Melty.events
|
|
17
|
+
for those captured drags and arms the drag once the cursor has moved
|
|
18
|
+
ARM_DISTANCE from the press — header clicks stay clicks.
|
|
19
|
+
|
|
20
|
+
* While armed, draw_collection renders the dragged child with closable=True
|
|
21
|
+
(DragDrop.dragged_item_kwargs), so it defers to the window layers and
|
|
22
|
+
floats; width/height are pinned to the size captured at pickup so the
|
|
23
|
+
view doesn't reflow when it leaves the parent wrapper, and a placeholder
|
|
24
|
+
(draw_placeholder) holds its inline slot open so the surrounding layout
|
|
25
|
+
doesn't shift. The drag itself is
|
|
26
|
+
invalidation-free, riding the closable-window fast path: the item's
|
|
27
|
+
cached tile blits at a moving window_pos (glue_window_to_cursor updates
|
|
28
|
+
it on the draw_state at dispatch time, not via kwargs) and _keep_alive
|
|
29
|
+
re-registers the window on its layer on frames where the (blitted)
|
|
30
|
+
source collection didn't run its deferring inline call. Invalidations
|
|
31
|
+
happen only at the gesture's edges — pickup, drop, cancel.
|
|
32
|
+
|
|
33
|
+
* Drop points come from the BVH: every draw_collection draw_state whose box
|
|
34
|
+
intersects a DROP_RADIUS square around the cursor contributes one slot
|
|
35
|
+
per gap (rows are the collection's live children, read from ds._children
|
|
36
|
+
and their BVH boxes). A line is drawn on every slot in radius, nearest
|
|
37
|
+
highlighted. Nothing pops: a line's opacity eases up FROM ZERO as it
|
|
38
|
+
enters the radius (so a slot sliding into range fades in rather than
|
|
39
|
+
appearing at a floor alpha), and all of the drop chrome — slot lines,
|
|
40
|
+
the home frame — is additionally scaled by the REVEAL ramp: invisible at
|
|
41
|
+
pickup, eased up to full over the first Toggles.Collection.dnd_reveal_distance
|
|
42
|
+
px of cumulative cursor travel (DragDrop.travel, accumulated per frame
|
|
43
|
+
in frame_update). The one thing that moves instantly is WHICH slot is
|
|
44
|
+
the nearest highlight — that snaps between lines with no cross-fade.
|
|
45
|
+
|
|
46
|
+
* The item's start position is also a drop target (the _HOME sentinel),
|
|
47
|
+
drawn as a subtle rect frame around the placeholder (draw_home) rather than
|
|
48
|
+
a slot line. It competes by distance like any slot — the cursor is "at
|
|
49
|
+
home" whenever it sits inside the placeholder rect (distance 0), so it wins
|
|
50
|
+
ties against the adjacent gap lines. Releasing on it cancels the reorder,
|
|
51
|
+
the natural target for a change of mind or an accidental short drag.
|
|
52
|
+
Because _HOME routes through the same "nothing selected → snap back" path
|
|
53
|
+
as releasing over empty space, the cancel needs no special commit logic
|
|
54
|
+
beyond skipping _commit for the sentinel.
|
|
55
|
+
|
|
56
|
+
* On release the reorder is applied the same way the undo manager applies a
|
|
57
|
+
restore: a CollectionMutation is registered in Melty.dnd_requests keyed
|
|
58
|
+
by the collection's draw_state; core_render's wrapper tail intercepts
|
|
59
|
+
that draw_state's next return, applies the mutation to the LIVE
|
|
60
|
+
collection in place (identity stable for live models) and reports
|
|
61
|
+
(True, reordered_dict), so the parent writes it back exactly as if the
|
|
62
|
+
user had edited it. Cross-collection moves register a Remove on the
|
|
63
|
+
source and an Insert on the target.
|
|
64
|
+
|
|
65
|
+
* The applied mutation and its inverse are recorded into UndoManager as a
|
|
66
|
+
Change(old=inverse, new=mutation) — the undo stack holds "insert x at
|
|
67
|
+
key a"-style records, never dict snapshots. Ctrl+Z routes the inverse
|
|
68
|
+
back through Melty.undo_requests and the wrapper tail applies it to the
|
|
69
|
+
live collection; both halves of a cross-collection move land in the
|
|
70
|
+
same frame, so frame-window grouping undoes them as one step.
|
|
71
|
+
|
|
72
|
+
The older drag/drop fields on Melty (dragged_item, drag_in_progress,
|
|
73
|
+
drag_drop_target, draw_drag_drop_target, ...) are a previous, separate
|
|
74
|
+
attempt and are not used here.
|
|
75
|
+
|
|
76
|
+
Immediate-mode items (no per-item @render_func): a view that paints its own
|
|
77
|
+
rows straight to the draw list (flat_button tabs, fast-dock rows) opts each
|
|
78
|
+
row in with DragDrop.on_drag(rect, key) and closes the body with
|
|
79
|
+
DragDrop.on_drop() — see the "immediate-mode API" section below. Pickup,
|
|
80
|
+
slot lines, home/cancel and commit all ride the machinery above; the only
|
|
81
|
+
behavioral difference is the drag's middle: there is no per-item draw_state
|
|
82
|
+
to float as a cached window, so the OWNER's tile is force-dirtied every
|
|
83
|
+
frame and the owner's body draws the ghost itself at the position on_drag
|
|
84
|
+
hands back. Drops don't go through Melty.dnd_requests either — the owner
|
|
85
|
+
receives a one-shot DropEvent from on_drop() and applies the mutation in
|
|
86
|
+
its own code, the immediate-mode way.
|
|
87
|
+
"""
|
|
88
|
+
import math
|
|
89
|
+
from dataclasses import dataclass
|
|
90
|
+
|
|
91
|
+
import meltygui_imgui as imgui
|
|
92
|
+
from meltygui.hdr_color import pack_color
|
|
93
|
+
|
|
94
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
95
|
+
from meltygui.core.windowing.glfw_utils import request_render
|
|
96
|
+
from meltygui.core.rendering.core_decoration import Core
|
|
97
|
+
from meltygui.core.rendering.window_decoration import window
|
|
98
|
+
|
|
99
|
+
# Slots farther than this from the cursor are neither drawn nor droppable.
|
|
100
|
+
DROP_RADIUS = 260.0
|
|
101
|
+
# The cursor must travel this far from the press before the drag arms (the
|
|
102
|
+
# dragged window detaches) - keeps header clicks from ever reordering.
|
|
103
|
+
ARM_DISTANCE = 2.0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ease(t):
|
|
108
|
+
"""Smoothstep: the ease applied to every drop-chrome opacity ramp — the
|
|
109
|
+
pickup reveal (DragDrop.reveal) and a slot line's fade over distance
|
|
110
|
+
(DragDrop.slot_alpha). Zero slope at both ends, so a ramp starts from
|
|
111
|
+
nothing without a visible onset and lands on full without a kink. To
|
|
112
|
+
change the feel of every fade at once, change this one function (an
|
|
113
|
+
ease-in-only alternative: t * t * t)."""
|
|
114
|
+
t = 0.0 if t < 0.0 else 1.0 if t > 1.0 else t
|
|
115
|
+
return t * t * (3.0 - 2.0 * t)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_VIEW_ID = "dnd_item"
|
|
119
|
+
_VIEW_ID_SUFFIX = "_" + _VIEW_ID
|
|
120
|
+
_EVENTS = ["left_mouse_drag", "left_mouse_drag_released"]
|
|
121
|
+
|
|
122
|
+
# Sentinel used as cls.nearest when the cursor is over the item's start
|
|
123
|
+
# position. It carries no insert index - dropping on it cancels the reorder.
|
|
124
|
+
_HOME = object()
|
|
125
|
+
|
|
126
|
+
# view_id prefix for immediate-mode drag handles. on_action prepends the
|
|
127
|
+
# owner's tile id + "_", so full event keys look like
|
|
128
|
+
# "<tile_id>_dnd_im:<key>" - _watch_for_pickup finds them by _IM_MARK.
|
|
129
|
+
_IM_PREFIX = "dnd_im:"
|
|
130
|
+
_IM_MARK = "_" + _IM_PREFIX
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class _ImItem:
|
|
135
|
+
"""One immediate-mode item as registered by on_drag this frame: enough
|
|
136
|
+
to pick it up (rect → grab offset/size/home) and to place it in its
|
|
137
|
+
owner's collection (index = on_drag call order = collection order)."""
|
|
138
|
+
ds: object # the OWNER view's draw_state (not a per-item one)
|
|
139
|
+
key: object
|
|
140
|
+
value: object
|
|
141
|
+
rect: tuple # (x0, y0, x1, y1) absolute
|
|
142
|
+
index: int
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class DragInfo:
|
|
147
|
+
"""Truthy return of on_drag while its item is the active drag: the ghost
|
|
148
|
+
rect (top-left glued to the cursor minus the grab offset) plus the draw
|
|
149
|
+
list to paint it into. `draw_list` is the OVERLAY list with its channel
|
|
150
|
+
already set (the owner's window list renders into a tile clipped to the
|
|
151
|
+
window rect — a ghost dragged past the edge would crop there; the
|
|
152
|
+
overlay is unclipped and above every window, same home as the slot
|
|
153
|
+
lines). The imgui cursor has also been placed at (x, y) for
|
|
154
|
+
text-position-based drawing. Draw with raw draw-list calls only — no
|
|
155
|
+
dummy/layout, or the window group swallows the rect and stretches the
|
|
156
|
+
view's measured content to the mouse — then call DragDrop.end_drag() to
|
|
157
|
+
restore the cursor."""
|
|
158
|
+
x: float
|
|
159
|
+
y: float
|
|
160
|
+
w: float
|
|
161
|
+
h: float
|
|
162
|
+
draw_list: object = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class DropEvent:
|
|
167
|
+
"""One-shot result handed to an immediate owner by on_drop().
|
|
168
|
+
|
|
169
|
+
kind: "reorder" — an item of THIS view moved (index → insert_index);
|
|
170
|
+
"insert" — something dropped IN from another collection (value
|
|
171
|
+
carries the dragged value; if the source was a
|
|
172
|
+
@render_func collection its Remove has ALREADY been
|
|
173
|
+
applied, so an ignoring handler drops the value on
|
|
174
|
+
the floor);
|
|
175
|
+
"remove" — this view's item was dropped into ANOTHER collection;
|
|
176
|
+
the handler should remove it locally.
|
|
177
|
+
index / insert_index are in on_drag call order (== collection order),
|
|
178
|
+
insert_index in pre-removal coordinates, same as Reorder."""
|
|
179
|
+
kind: str
|
|
180
|
+
key: object
|
|
181
|
+
value: object
|
|
182
|
+
index: int = None
|
|
183
|
+
insert_index: int = None
|
|
184
|
+
|
|
185
|
+
def apply(self, coll):
|
|
186
|
+
"""Convenience: apply this event to a live list/dict via the same
|
|
187
|
+
mutation classes the render_func path uses. Returns changed."""
|
|
188
|
+
if self.kind == "reorder":
|
|
189
|
+
k = self.index if isinstance(coll, list) else self.key
|
|
190
|
+
changed, _c, _inv = Reorder(k, self.insert_index).apply(coll)
|
|
191
|
+
elif self.kind == "insert":
|
|
192
|
+
changed, _c, _inv = Insert(self.key, self.value,
|
|
193
|
+
self.insert_index).apply(coll)
|
|
194
|
+
elif self.kind == "remove":
|
|
195
|
+
k = self.index if isinstance(coll, list) else self.key
|
|
196
|
+
changed, _c, _inv = Remove(k).apply(coll)
|
|
197
|
+
else:
|
|
198
|
+
changed = False
|
|
199
|
+
return changed
|
|
200
|
+
|
|
201
|
+
@window
|
|
202
|
+
class DragDrop:
|
|
203
|
+
item_ds = None # the dragged item's draw_state
|
|
204
|
+
source_ds = None # the collection draw_state the item came from
|
|
205
|
+
active = False
|
|
206
|
+
value = None # the dragged value (live object)
|
|
207
|
+
key = None # the item's key in the source collection
|
|
208
|
+
grab_offset = (0.0, 0.0) # cursor - item top-left at pickup
|
|
209
|
+
size = (None, None) # item (w, h) at pickup; pins the floating window
|
|
210
|
+
home_rect = None # (abs_left, abs_top) of the inline slot at pickup
|
|
211
|
+
slots = () # this frame's (y, x0, x1, h, coll_ds, insert_idx)
|
|
212
|
+
nearest = None
|
|
213
|
+
# Cumulative cursor path length (px) since the press - drives the pickup
|
|
214
|
+
# fade ramp (see reveal()). Path length, not displacement: the chrome
|
|
215
|
+
# never fades back out when the cursor returns toward the press point.
|
|
216
|
+
travel = 0.0
|
|
217
|
+
_last_mouse = None # cursor at the last travel sample
|
|
218
|
+
|
|
219
|
+
# ── immediate-mode state ─────────────────────────────────────────────
|
|
220
|
+
immediate = False # the active drag is an on_drag item (no item_ds)
|
|
221
|
+
im_index = None # dragged item's on_drag call-order index
|
|
222
|
+
_im_items = {} # full event view_id → _ImItem (pickup lookup)
|
|
223
|
+
_im_lists = {} # owner ds → (frame_count, [_ImItem...]) this frame
|
|
224
|
+
_pending_drops = {} # owner ds → DropEvent, popped by on_drop
|
|
225
|
+
_ghost_saved = None # (x, y) cursor to restore in end_drag, or None
|
|
226
|
+
|
|
227
|
+
# ── wrapper hooks (called from core_render for every view) ──────────
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def register_item(cls, draw_state):
|
|
231
|
+
"""Subscribe an eligible collection item's header as a drag handle.
|
|
232
|
+
|
|
233
|
+
Runs for every view every frame — keep the early-outs cheap. The
|
|
234
|
+
subscription is what lets the input handler capture the gesture at
|
|
235
|
+
mouse-down; the events are read globally in frame_update, so this
|
|
236
|
+
only needs to have run on the frame the press lands."""
|
|
237
|
+
coll_ds = draw_state._collection_draw_state
|
|
238
|
+
if coll_ds is None or draw_state.closable:
|
|
239
|
+
return
|
|
240
|
+
# Headerless items (e.g. tab-bar buttons) opt in with dnd_handle=True
|
|
241
|
+
# in their kwargs: the whole rect becomes the drag handle instead of
|
|
242
|
+
# the header band.
|
|
243
|
+
whole_rect = (draw_state._kwargs or {}).get("dnd_handle", False)
|
|
244
|
+
if not draw_state.header_height and not whole_rect:
|
|
245
|
+
return
|
|
246
|
+
if (draw_state.abs_left is None or draw_state.abs_top is None
|
|
247
|
+
or not draw_state.width):
|
|
248
|
+
return
|
|
249
|
+
if not isinstance(coll_ds._raw_input_value, (dict, list)):
|
|
250
|
+
return
|
|
251
|
+
if whole_rect:
|
|
252
|
+
rect = (draw_state.abs_left, draw_state.abs_top,
|
|
253
|
+
draw_state.abs_left + draw_state.width,
|
|
254
|
+
draw_state.abs_top + (draw_state.height or 0))
|
|
255
|
+
else:
|
|
256
|
+
rect = draw_state.get_header_rect()
|
|
257
|
+
# priority_delta=1 beats lower-depth subscriptions (e.g. a text
|
|
258
|
+
# editor's selection drag registered over its whole body) to the
|
|
259
|
+
# header band only.
|
|
260
|
+
draw_state.on_action(_EVENTS, view_id=_VIEW_ID,
|
|
261
|
+
rect=rect,
|
|
262
|
+
priority_delta=1)
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def is_dragged_item(cls, draw_state):
|
|
266
|
+
return cls.active and draw_state is cls.item_ds
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def glue_window_to_cursor(cls, draw_state):
|
|
270
|
+
"""Pin the floating window under the grab point. Delta-correct
|
|
271
|
+
window_pos using the live abs position — abs_left/top are linear in
|
|
272
|
+
window_pos, so one step lands exactly regardless of parent offsets,
|
|
273
|
+
anchors or ancestor scroll."""
|
|
274
|
+
left, top = draw_state.abs_left, draw_state.abs_top
|
|
275
|
+
if left is None or top is None:
|
|
276
|
+
return
|
|
277
|
+
mx, my = imgui.get_io().mouse_pos
|
|
278
|
+
cur = draw_state.window_pos or (0.0, 0.0)
|
|
279
|
+
draw_state.window_pos = (int(cur[0] + (mx - cls.grab_offset[0]) - left),
|
|
280
|
+
int(cur[1] + (my - cls.grab_offset[1]) - top))
|
|
281
|
+
|
|
282
|
+
# ── drop-chrome opacity ──────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def reveal(cls):
|
|
286
|
+
"""0..1 multiplier on every piece of drop chrome (slot lines, home
|
|
287
|
+
frame): 0 at pickup, eased up to 1 once the cursor has travelled
|
|
288
|
+
Toggles.Collection.dnd_reveal_distance px in total — the little UI
|
|
289
|
+
elements grow in with the gesture instead of popping into existence
|
|
290
|
+
the moment the drag arms."""
|
|
291
|
+
distance = Toggles.Collection.dnd_reveal_distance
|
|
292
|
+
if not distance or distance <= 0.0:
|
|
293
|
+
return 1.0
|
|
294
|
+
return _ease(cls.travel / distance)
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def slot_alpha(cls, dist, nearest):
|
|
298
|
+
"""Opacity of one slot line. The NEAREST line is the active drop
|
|
299
|
+
zone: full strength, switching instantly between lines. Every other
|
|
300
|
+
line fades with its distance from the probe point — from ZERO at
|
|
301
|
+
DROP_RADIUS (so a slot sliding into range eases in from nothing) up
|
|
302
|
+
to line_alpha at distance 0. Both are scaled by the pickup reveal."""
|
|
303
|
+
# [tint=(0.95, 0.75, 0.25)]
|
|
304
|
+
nearest_alpha = 0.95
|
|
305
|
+
# [tint=(0.55, 0.85, 0.95)]
|
|
306
|
+
line_alpha = 0.50
|
|
307
|
+
if nearest:
|
|
308
|
+
return nearest_alpha * cls.reveal()
|
|
309
|
+
fade = 1.0 - dist / DROP_RADIUS
|
|
310
|
+
return line_alpha * _ease(fade) * cls.reveal()
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def home_alpha(cls, active):
|
|
314
|
+
"""Opacity of the home (cancel-zone) frame: lifted while the cursor
|
|
315
|
+
is over it (still subtle — it's a cancel zone, not a reorder target),
|
|
316
|
+
faint otherwise; both scaled by the pickup reveal."""
|
|
317
|
+
# [tint=(0.95, 0.75, 0.25)]
|
|
318
|
+
active_alpha = 0.45
|
|
319
|
+
# [tint=(0.55, 0.85, 0.95)]
|
|
320
|
+
rest_alpha = 0.16
|
|
321
|
+
return (active_alpha if active else rest_alpha) * cls.reveal()
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def _start_travel(cls, ev):
|
|
325
|
+
"""Seed the reveal ramp at pickup: the displacement from the press
|
|
326
|
+
so far (>= ARM_DISTANCE) counts as travel, and the cursor's current
|
|
327
|
+
position becomes the first sample for the per-frame accumulation."""
|
|
328
|
+
cls.travel = math.hypot(ev.total_dx, ev.total_dy)
|
|
329
|
+
cls._last_mouse = (ev.x, ev.y)
|
|
330
|
+
|
|
331
|
+
@classmethod
|
|
332
|
+
def _track_travel(cls, mx, my):
|
|
333
|
+
"""Add this frame's cursor movement to the cumulative travel."""
|
|
334
|
+
last = cls._last_mouse
|
|
335
|
+
if last is not None:
|
|
336
|
+
cls.travel += math.hypot(mx - last[0], my - last[1])
|
|
337
|
+
cls._last_mouse = (mx, my)
|
|
338
|
+
|
|
339
|
+
# ── immediate-mode API (items without a @render_func) ────────────────
|
|
340
|
+
#
|
|
341
|
+
# For views that render their own rows straight to the draw list. Call
|
|
342
|
+
# per item, in collection order, inside the view body:
|
|
343
|
+
#
|
|
344
|
+
# drag = DragDrop.on_drag((x0, y0, x1, y1), key=path)
|
|
345
|
+
# if drag:
|
|
346
|
+
# # cursor and channel already set to draw the ghost, draw-list only
|
|
347
|
+
# dl.add_rect_filled(drag.x, drag.y, drag.x + drag.w, ...)
|
|
348
|
+
# DragDrop.end_drag()
|
|
349
|
+
# continue # leave the inline slot empty (home target)
|
|
350
|
+
# ... draw the row normally ...
|
|
351
|
+
#
|
|
352
|
+
# and once after the loop:
|
|
353
|
+
#
|
|
354
|
+
# drop = DragDrop.on_drop(horizontal=True)
|
|
355
|
+
# if drop:
|
|
356
|
+
# drop.apply(my_list) # or handle the drop by hand
|
|
357
|
+
#
|
|
358
|
+
# The owner draw_state comes from melty.draw_state_stack (or pass
|
|
359
|
+
# draw_state=). While one of its items is dragged the owner's tile is
|
|
360
|
+
# force-dirtied every frame (frame_update), so the body re-runs and the
|
|
361
|
+
# ghost tracks the cursor - the else is managed for you.
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def on_drag(cls, source_rect, key, value=None, draw_state=None):
|
|
365
|
+
"""Register `source_rect` as the drag handle for item `key` of the
|
|
366
|
+
current view, and — when this item IS the active drag — arrange the
|
|
367
|
+
draw list for ghost drawing and return a truthy DragInfo.
|
|
368
|
+
|
|
369
|
+
value: what a cross-collection drop delivers (defaults to key).
|
|
370
|
+
Returns None while the item is at rest (or another item drags)."""
|
|
371
|
+
cls.end_drag() # clean for the previous item, if its caller didn't
|
|
372
|
+
meltygui = Core.melty
|
|
373
|
+
if draw_state is None:
|
|
374
|
+
stack = meltygui.draw_state_stack
|
|
375
|
+
draw_state = stack[-1] if stack else None
|
|
376
|
+
if draw_state is None:
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
frame = meltygui.frame_count
|
|
380
|
+
entry = cls._im_lists.get(draw_state)
|
|
381
|
+
if entry is None or entry[0] != frame:
|
|
382
|
+
entry = (frame, [])
|
|
383
|
+
cls._im_lists[draw_state] = entry
|
|
384
|
+
item = _ImItem(draw_state, key, key if value is None else value,
|
|
385
|
+
tuple(source_rect), len(entry[1]))
|
|
386
|
+
entry[1].append(item)
|
|
387
|
+
|
|
388
|
+
view_id = _IM_PREFIX + str(key)
|
|
389
|
+
cls._im_items[str(draw_state._tile_id) + "_" + view_id] = item
|
|
390
|
+
# Same subscription as register_item: the input system captures the
|
|
391
|
+
# gesture at mouse-down and keeps delivering drag events to this id
|
|
392
|
+
# for the whole drag; priority_delta=1 beats same-depth handlers.
|
|
393
|
+
draw_state.on_action(_EVENTS, view_id=view_id, rect=item.rect,
|
|
394
|
+
priority_delta=1)
|
|
395
|
+
|
|
396
|
+
if (cls.active and cls.immediate and cls.source_ds is draw_state
|
|
397
|
+
and cls.key == key):
|
|
398
|
+
return cls._ghost_begin()
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
@classmethod
|
|
402
|
+
def end_drag(cls):
|
|
403
|
+
"""Restore what _ghost_begin set: put the imgui cursor back where the
|
|
404
|
+
view's flow had it. Safe to call when no ghost is open (no-op) —
|
|
405
|
+
on_drag/on_drop also call it, so a forgotten end_drag heals at the
|
|
406
|
+
next DragDrop call (the overlay channel needs no restore: every
|
|
407
|
+
overlay user sets its own channel before drawing)."""
|
|
408
|
+
if cls._ghost_saved is None:
|
|
409
|
+
return
|
|
410
|
+
imgui.set_cursor_screen_pos(cls._ghost_saved)
|
|
411
|
+
cls._ghost_saved = None
|
|
412
|
+
|
|
413
|
+
@classmethod
|
|
414
|
+
def on_drop(cls, horizontal=False, draw_state=None):
|
|
415
|
+
"""Close an immediate owner's body: publish its drop slots (derived
|
|
416
|
+
from this frame's on_drag rects — one insert-before line per item
|
|
417
|
+
plus one append-after-last) and return the pending DropEvent if a
|
|
418
|
+
drop landed here since the last body run, else None. Call AFTER all
|
|
419
|
+
on_drag calls; horizontal=True gives vertical insertion lines (a tab
|
|
420
|
+
bar / row of items)."""
|
|
421
|
+
cls.end_drag()
|
|
422
|
+
meltygui = Core.melty
|
|
423
|
+
if draw_state is None:
|
|
424
|
+
stack = meltygui.draw_state_stack
|
|
425
|
+
draw_state = stack[-1] if stack else None
|
|
426
|
+
if draw_state is None:
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
# Slot geometry through the same _dnd_extra_slots gauntlet
|
|
430
|
+
# draw_collection_as_tabs runs - radius, occlusion and clip-space
|
|
431
|
+
# clamping in _collection_body apply here.
|
|
432
|
+
draw_state._dnd_immediate = True
|
|
433
|
+
slots = []
|
|
434
|
+
entry = cls._im_lists.get(draw_state)
|
|
435
|
+
if entry is not None and entry[0] == meltygui.frame_count:
|
|
436
|
+
dragged_key = (cls.key if cls.active and cls.immediate
|
|
437
|
+
and cls.source_ds is draw_state else _HOME)
|
|
438
|
+
last = None
|
|
439
|
+
for it in entry[1]:
|
|
440
|
+
if it.key == dragged_key:
|
|
441
|
+
continue # its gap is the home/cancel target
|
|
442
|
+
x0, y0, x1, y1 = it.rect
|
|
443
|
+
if horizontal:
|
|
444
|
+
slots.append((it.index, y0, y1, x0 - 2, True))
|
|
445
|
+
else:
|
|
446
|
+
slots.append((it.index, x0, x1, y0 - 2, False))
|
|
447
|
+
last = it
|
|
448
|
+
if last is not None:
|
|
449
|
+
x0, y0, x1, y1 = last.rect
|
|
450
|
+
if horizontal:
|
|
451
|
+
slots.append((last.index + 1, y0, y1, x1 + 3, True))
|
|
452
|
+
else:
|
|
453
|
+
slots.append((last.index + 1, x0, x1, y1 + 3, False))
|
|
454
|
+
draw_state._dnd_extra_slots = slots
|
|
455
|
+
|
|
456
|
+
return cls._pending_drops.pop(draw_state, None)
|
|
457
|
+
|
|
458
|
+
@classmethod
|
|
459
|
+
def _ghost_begin(cls):
|
|
460
|
+
"""Arrange ghost drawing: remember the cursor, aim the OVERLAY draw
|
|
461
|
+
list at the same never-stencil-masked top channel the slot lines
|
|
462
|
+
use, and set the cursor to the ghost's top-left — cursor minus grab
|
|
463
|
+
offset, the same glue as the floating-window path."""
|
|
464
|
+
meltygui = Core.melty
|
|
465
|
+
mx, my = imgui.get_io().mouse_pos
|
|
466
|
+
gx, gy = mx - cls.grab_offset[0], my - cls.grab_offset[1]
|
|
467
|
+
if cls._ghost_saved is None:
|
|
468
|
+
cls._ghost_saved = tuple(imgui.get_cursor_screen_pos())
|
|
469
|
+
overlay = imgui.get_overlay_draw_list()
|
|
470
|
+
if meltygui._overlay_channels_active:
|
|
471
|
+
overlay.channels_set_current(meltygui.max_layer - 5)
|
|
472
|
+
imgui.set_cursor_screen_pos((gx, gy))
|
|
473
|
+
w, h = cls.size
|
|
474
|
+
return DragInfo(gx, gy, w or 0.0, h or 0.0, overlay)
|
|
475
|
+
|
|
476
|
+
@classmethod
|
|
477
|
+
def _begin_immediate(cls, item, ev):
|
|
478
|
+
"""Pick an on_drag item up — the immediate twin of _begin. No
|
|
479
|
+
item_ds/floating window: the owner's body draws the ghost."""
|
|
480
|
+
cls.active = True
|
|
481
|
+
cls.immediate = True
|
|
482
|
+
cls.item_ds = None
|
|
483
|
+
cls.source_ds = item.ds
|
|
484
|
+
cls.key = item.key
|
|
485
|
+
cls.value = item.value
|
|
486
|
+
cls.im_index = item.index
|
|
487
|
+
x0, y0, x1, y1 = item.rect
|
|
488
|
+
down_x, down_y = ev.x - ev.total_dx, ev.y - ev.total_dy
|
|
489
|
+
cls.grab_offset = (max(0.0, down_x - x0), max(0.0, down_y - y0))
|
|
490
|
+
cls.size = (x1 - x0, y1 - y0)
|
|
491
|
+
cls.home_rect = (x0, y0)
|
|
492
|
+
Core.melty.dnd_home_rect = (x0, y0, cls.size[0], cls.size[1])
|
|
493
|
+
cls.slots = ()
|
|
494
|
+
cls.nearest = None
|
|
495
|
+
cls._start_travel(ev)
|
|
496
|
+
cls._wake(item.ds)
|
|
497
|
+
|
|
498
|
+
@classmethod
|
|
499
|
+
def _queue_drop(cls, ds, event):
|
|
500
|
+
cls._pending_drops[ds] = event
|
|
501
|
+
cls._wake(ds)
|
|
502
|
+
|
|
503
|
+
# ── draw_collection hooks ────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
@classmethod
|
|
506
|
+
def is_dragged_child(cls, collection_ds, key):
|
|
507
|
+
return (cls.active and collection_ds is cls.source_ds
|
|
508
|
+
and key == cls.key)
|
|
509
|
+
|
|
510
|
+
@classmethod
|
|
511
|
+
def dragged_item_kwargs(cls):
|
|
512
|
+
"""Kwarg overrides draw_collection applies to the dragged child so it
|
|
513
|
+
renders as a detached floating window of its pre-pickup size."""
|
|
514
|
+
kw = {
|
|
515
|
+
"closable": True,
|
|
516
|
+
"detached": True, # stays out of root_draw_states bookkeeping
|
|
517
|
+
"swoosh": False,
|
|
518
|
+
"auto_resize": False,
|
|
519
|
+
"use_cache": True,
|
|
520
|
+
}
|
|
521
|
+
w, h = cls.size
|
|
522
|
+
if w:
|
|
523
|
+
kw["width"] = w
|
|
524
|
+
if h:
|
|
525
|
+
kw["height"] = h
|
|
526
|
+
return kw
|
|
527
|
+
|
|
528
|
+
@classmethod
|
|
529
|
+
def draw_placeholder(cls, horizontal=False, item_spacing_y=1,
|
|
530
|
+
style_manager=None, draw_bg=None):
|
|
531
|
+
"""Hold the dragged item's inline slot open at its pickup size so the
|
|
532
|
+
collection's layout doesn't shift while the item floats. Drawn inside
|
|
533
|
+
the collection body, so it bakes into the collection's tile on the
|
|
534
|
+
one pickup re-render — zero per-frame cost. Rendered with the
|
|
535
|
+
standard draw_bg (passed in by draw_collection — importing it here
|
|
536
|
+
would be circular) one bg level deeper than the collection, where the
|
|
537
|
+
displaced item's own background sat, so it reads as an empty socket."""
|
|
538
|
+
w, h = cls.size
|
|
539
|
+
if not w or not h:
|
|
540
|
+
return
|
|
541
|
+
meltygui = Core.melty
|
|
542
|
+
# The bg rect comes from the abs position captured at pickup (the
|
|
543
|
+
# item's inline slot) because the cursor here, mid-frame re-render,
|
|
544
|
+
# proved unreliable. The slot doesn't move during the drag (that's
|
|
545
|
+
# the placeholder's whole point) and the bake travels with the tile.
|
|
546
|
+
x, y = cls.home_rect if cls.home_rect is not None else imgui.get_cursor_screen_pos()
|
|
547
|
+
if draw_bg is not None and style_manager is not None:
|
|
548
|
+
# bypass=True calls the raw function - draw_bg is @inline-wrapped
|
|
549
|
+
# and without it the wrapper gives it a draw_state and blakes out
|
|
550
|
+
# itself (everywhere but here). Sibling item bgs draw at their
|
|
551
|
+
# wrapper's get_channel() - 2 = this body's get_channel() - 1
|
|
552
|
+
# (children run one depth deeper); match it, then restore the
|
|
553
|
+
# original channel.
|
|
554
|
+
|
|
555
|
+
if meltygui.channels_split:
|
|
556
|
+
draw_list = imgui.get_window_draw_list()
|
|
557
|
+
draw_list.channels_set_current(
|
|
558
|
+
max(0, min(meltygui.get_channel() - 3, meltygui.max_depth - 1)))
|
|
559
|
+
|
|
560
|
+
imgui.get_window_draw_list().add_rect_filled(
|
|
561
|
+
x, y, x + w, y + h, pack_color(1.0, 1.0, 1.0, 0.05),
|
|
562
|
+
rounding=5.0)
|
|
563
|
+
draw_bg(bypass=True, left=x, top=y, width=w, height=h - 2,
|
|
564
|
+
rounding=5.0, bg_offset=1, depth=meltygui.shadow_depth,
|
|
565
|
+
opacity=1.0, nested_bg=True, style_manager=style_manager)
|
|
566
|
+
if meltygui.channels_split:
|
|
567
|
+
imgui.get_window_draw_list().channels_set_current(
|
|
568
|
+
max(0, min(meltygui.get_channel(), meltygui.max_depth - 1)))
|
|
569
|
+
else:
|
|
570
|
+
imgui.get_window_draw_list().add_rect(
|
|
571
|
+
x + 2, y, x + w - 2, y + h - 2,
|
|
572
|
+
pack_color(1.0, 1.0, 1.0, 0.10),
|
|
573
|
+
rounding=4.0, thickness=1.0)
|
|
574
|
+
# The slot dummies anchor on the same pickup position as the bg - not
|
|
575
|
+
# on the incoming cursor or the floating window's win_* (glued to the
|
|
576
|
+
# mouse): the window group's item_rect swallows every submitted item,
|
|
577
|
+
# so a mid-drag re-render would otherwise stretch the collection's
|
|
578
|
+
# measured width/height out to where the drag has wandered.
|
|
579
|
+
if horizontal:
|
|
580
|
+
imgui.set_cursor_screen_pos((x, y))
|
|
581
|
+
imgui.dummy(w, h)
|
|
582
|
+
imgui.same_line(spacing=0)
|
|
583
|
+
else:
|
|
584
|
+
# 0-width dummy at the slot's right edge: match the row's width
|
|
585
|
+
# contribution without spanning past it.
|
|
586
|
+
imgui.set_cursor_screen_pos((x + w, y))
|
|
587
|
+
imgui.dummy(0, int(h))
|
|
588
|
+
imgui.dummy(0, item_spacing_y)
|
|
589
|
+
|
|
590
|
+
@classmethod
|
|
591
|
+
def draw_home_blank(cls):
|
|
592
|
+
"""Blit-layer placeholder: paint the blank socket over the home slot
|
|
593
|
+
of a freshly blitted tile. Called by blit_offscreen (via
|
|
594
|
+
Melty.dnd_home_rect) right after it draws a cached tile image that
|
|
595
|
+
contains the slot — the tile's pixels there can be stale, captured
|
|
596
|
+
while the floating window still overlapped its slot. Draws on the
|
|
597
|
+
current channel, directly over the image."""
|
|
598
|
+
if cls.home_rect is None:
|
|
599
|
+
return
|
|
600
|
+
w, h = cls.size
|
|
601
|
+
if not w or not h:
|
|
602
|
+
return
|
|
603
|
+
x, y = cls.home_rect
|
|
604
|
+
meltygui = Core.melty
|
|
605
|
+
sm = meltygui.style_manager
|
|
606
|
+
if sm is not None:
|
|
607
|
+
from meltygui.view.decoration_view import draw_bg
|
|
608
|
+
draw_bg(bypass=True, left=x, top=y, width=w, height=h - 2,
|
|
609
|
+
rounding=5.0, bg_offset=1, depth=meltygui.shadow_depth,
|
|
610
|
+
opacity=1.0, nested_bg=True, style_manager=sm)
|
|
611
|
+
else:
|
|
612
|
+
imgui.get_window_draw_list().add_rect(
|
|
613
|
+
x + 2, y, x + w - 2, y + h - 2,
|
|
614
|
+
pack_color(1.0, 1.0, 1.0, 0.10),
|
|
615
|
+
rounding=4.0, thickness=1.0)
|
|
616
|
+
|
|
617
|
+
# ── per-frame update (called from Melty.end_frame) ───────────────────
|
|
618
|
+
|
|
619
|
+
@classmethod
|
|
620
|
+
def frame_update(cls):
|
|
621
|
+
meltygui = Core.melty
|
|
622
|
+
# A body that opened a ghost and never closed it can't be repaired
|
|
623
|
+
# here (popping its window outside the window would unbalance imgui's
|
|
624
|
+
# stack) - just drop the record so the next drag starts clean.
|
|
625
|
+
cls._ghost_saved = None
|
|
626
|
+
if not cls.active:
|
|
627
|
+
cls._housekeep()
|
|
628
|
+
cls._watch_for_pickup(meltygui)
|
|
629
|
+
if not cls.active:
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
if cls.immediate:
|
|
633
|
+
if (cls.source_ds is None or cls.source_ds.closed
|
|
634
|
+
or cls.source_ds.abs_closed):
|
|
635
|
+
cls._reset()
|
|
636
|
+
return
|
|
637
|
+
elif (cls.item_ds is None or cls.source_ds is None
|
|
638
|
+
or cls.source_ds.closed or cls.source_ds.abs_closed):
|
|
639
|
+
cls._reset()
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
mx, my = imgui.get_io().mouse_pos
|
|
643
|
+
cls._track_travel(mx, my)
|
|
644
|
+
cls._compute_slots(mx, my)
|
|
645
|
+
cls._draw_slots()
|
|
646
|
+
cls._draw_home()
|
|
647
|
+
|
|
648
|
+
if not meltygui.event_handler.is_down("left_mouse"):
|
|
649
|
+
# _HOME (or None) means "drop back at the home" - no reorder.
|
|
650
|
+
if cls.nearest is not None and cls.nearest is not _HOME:
|
|
651
|
+
cls._commit()
|
|
652
|
+
cls._reset()
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
if cls.immediate:
|
|
656
|
+
# Immediate ghosts are drawn by the owner's own body straight
|
|
657
|
+
# into its render list - force the owner's tile dirty every frame
|
|
658
|
+
# so the body re-runs and the ghost tracks the cursor. This is
|
|
659
|
+
# the immediate-mode tradeoff; render-only items ride the
|
|
660
|
+
# invalidation-free floating-window path below instead.
|
|
661
|
+
cls._wake(cls.source_ds)
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
# NO per-frame invalidation: the floating window is a cached tile
|
|
665
|
+
# blitted at its moving window_pos - the closable-window fast path.
|
|
666
|
+
# The only per-frame work is keeping it registered with its layer,
|
|
667
|
+
# usually done by the deferring inline call in the source
|
|
668
|
+
# collection's body, which a clean (blitted) collection rightly skips.
|
|
669
|
+
cls._keep_alive(meltygui)
|
|
670
|
+
|
|
671
|
+
@classmethod
|
|
672
|
+
def _housekeep(cls):
|
|
673
|
+
"""Idle-time pruning of the immediate registries. Entries are
|
|
674
|
+
re-registered every body run, so clearing costs nothing beyond a
|
|
675
|
+
re-fill — but never mid-gesture (a press could be captured on an
|
|
676
|
+
entry we'd need at arm time)."""
|
|
677
|
+
if cls._pending_drops:
|
|
678
|
+
for ds in [d for d in cls._pending_drops if d.closed]:
|
|
679
|
+
del cls._pending_drops[ds]
|
|
680
|
+
if (len(cls._im_items) > 4096
|
|
681
|
+
and not Core.melty.event_handler.is_down("left_mouse")):
|
|
682
|
+
cls._im_items.clear()
|
|
683
|
+
cls._im_lists.clear()
|
|
684
|
+
|
|
685
|
+
@classmethod
|
|
686
|
+
def _keep_alive(cls, meltygui):
|
|
687
|
+
"""Re-register the dragged window on its layer for this frame's
|
|
688
|
+
dispatch when the source collection's body didn't run to defer it.
|
|
689
|
+
Runs from end_frame BEFORE the layer loop."""
|
|
690
|
+
layers = meltygui.layers
|
|
691
|
+
if not layers:
|
|
692
|
+
return
|
|
693
|
+
item = cls.item_ds
|
|
694
|
+
# Not detached yet (the pickup's wake hasn't re-rendered the
|
|
695
|
+
# item with the closable=True): the deferral block hasn't
|
|
696
|
+
# configured the window - let it, next frame, rather than dispatching
|
|
697
|
+
# an inline-configured draw_state as the window.
|
|
698
|
+
active_layer = (item._kwargs or {}).get("active_layer")
|
|
699
|
+
if active_layer is None or not item.closable:
|
|
700
|
+
return
|
|
701
|
+
for layer in layers:
|
|
702
|
+
if item in layer:
|
|
703
|
+
return
|
|
704
|
+
layers[min(int(active_layer), len(layers) - 1)].append(item)
|
|
705
|
+
|
|
706
|
+
# ── internals ────────────────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
@classmethod
|
|
709
|
+
def _watch_for_pickup(cls, meltygui):
|
|
710
|
+
"""Find an armed header drag among this frame's events and pick the
|
|
711
|
+
item up. Events are delivered to the captured view id for the whole
|
|
712
|
+
gesture even when the view itself stopped re-rendering, so reading
|
|
713
|
+
them here (not in the wrapper) survives cache-skipped frames."""
|
|
714
|
+
cache = getattr(meltygui, "cache", None)
|
|
715
|
+
if cache is None:
|
|
716
|
+
return
|
|
717
|
+
# An active imgui widget (e.g. a drag_float living in a header) owns
|
|
718
|
+
# the gesture - don't also pick the item up.
|
|
719
|
+
if meltygui.imgui_active or meltygui.imgui_popup_open:
|
|
720
|
+
return
|
|
721
|
+
for view_id, events in meltygui.events.items():
|
|
722
|
+
if not isinstance(view_id, str):
|
|
723
|
+
continue
|
|
724
|
+
if view_id.endswith(_VIEW_ID_SUFFIX):
|
|
725
|
+
ev = events.get("left_mouse_drag")
|
|
726
|
+
if ev is None:
|
|
727
|
+
continue
|
|
728
|
+
if math.hypot(ev.total_dx, ev.total_dy) < ARM_DISTANCE:
|
|
729
|
+
continue
|
|
730
|
+
ds = cache.key_to_draw_state.get(view_id[:-len(_VIEW_ID_SUFFIX)])
|
|
731
|
+
if ds is None:
|
|
732
|
+
continue
|
|
733
|
+
cls._begin(ds, ev)
|
|
734
|
+
return
|
|
735
|
+
if _IM_MARK in view_id:
|
|
736
|
+
ev = events.get("left_mouse_drag")
|
|
737
|
+
if ev is None:
|
|
738
|
+
continue
|
|
739
|
+
if math.hypot(ev.total_dx, ev.total_dy) < ARM_DISTANCE:
|
|
740
|
+
continue
|
|
741
|
+
item = cls._im_items.get(view_id)
|
|
742
|
+
if item is None or item.ds.closed or item.ds.abs_closed:
|
|
743
|
+
continue
|
|
744
|
+
cls._begin_immediate(item, ev)
|
|
745
|
+
return
|
|
746
|
+
|
|
747
|
+
@classmethod
|
|
748
|
+
def _begin(cls, draw_state, ev):
|
|
749
|
+
coll_ds = draw_state._collection_draw_state
|
|
750
|
+
if coll_ds is None:
|
|
751
|
+
return
|
|
752
|
+
coll = coll_ds._raw_input_value
|
|
753
|
+
key = (draw_state._kwargs or {}).get("key", None)
|
|
754
|
+
if isinstance(coll, dict):
|
|
755
|
+
if key not in coll:
|
|
756
|
+
return
|
|
757
|
+
elif isinstance(coll, list):
|
|
758
|
+
if not (isinstance(key, int) and 0 <= key < len(coll)):
|
|
759
|
+
return
|
|
760
|
+
else:
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
cls.active = True
|
|
764
|
+
cls.item_ds = draw_state
|
|
765
|
+
cls.source_ds = coll_ds
|
|
766
|
+
cls.key = key
|
|
767
|
+
cls.value = coll[key]
|
|
768
|
+
down_x, down_y = ev.x - ev.total_dx, ev.y - ev.total_dy
|
|
769
|
+
left = draw_state.abs_left if draw_state.abs_left is not None else down_x
|
|
770
|
+
top = draw_state.abs_top if draw_state.abs_top is not None else down_y
|
|
771
|
+
cls.grab_offset = (max(0.0, down_x - left), max(0.0, down_y - top))
|
|
772
|
+
cls.size = (draw_state.width, draw_state.height)
|
|
773
|
+
# The item's inline position, captured while it still IS inline -
|
|
774
|
+
# the placeholder bg draws at this rect for the whole drag. Mirrored
|
|
775
|
+
# into Melty so blit_offscreen can repaint the socket over cached
|
|
776
|
+
# tiles that contain the slot (see draw_home_blank).
|
|
777
|
+
cls.home_rect = (left, top)
|
|
778
|
+
Core.melty.dnd_home_rect = (left, top, cls.size[0] or 0, cls.size[1] or 0)
|
|
779
|
+
cls.slots = ()
|
|
780
|
+
cls.nearest = None
|
|
781
|
+
cls._start_travel(ev)
|
|
782
|
+
# Reflow the collection (the item leaves the UI flow) and re-render
|
|
783
|
+
# the item as a window.
|
|
784
|
+
cls._wake(coll_ds)
|
|
785
|
+
cls._wake(draw_state)
|
|
786
|
+
|
|
787
|
+
@classmethod
|
|
788
|
+
def _wake(cls, draw_state):
|
|
789
|
+
"""Minimal repaint at a gesture edge: force-dirty just this view's
|
|
790
|
+
tile. invalidate marks the tile plus its ancestor path, so the body
|
|
791
|
+
re-runs next frame while siblings and descendants keep blitting.
|
|
792
|
+
(The previous invalidate_up cascaded over descendants too — whole
|
|
793
|
+
subtrees re-captured their tiles, which is where the blit smearing
|
|
794
|
+
came from.)"""
|
|
795
|
+
cache = getattr(Core.melty, "cache", None)
|
|
796
|
+
if cache is not None and draw_state._tile_id is not None:
|
|
797
|
+
cache.invalidate(draw_state._tile_id, force=True)
|
|
798
|
+
request_render()
|
|
799
|
+
|
|
800
|
+
@classmethod
|
|
801
|
+
def _inside_dragged(cls, draw_state):
|
|
802
|
+
node, hops = draw_state, 0
|
|
803
|
+
while node is not None and hops < 64:
|
|
804
|
+
if node is cls.item_ds:
|
|
805
|
+
return True
|
|
806
|
+
parent = node._parent
|
|
807
|
+
if parent is node:
|
|
808
|
+
break
|
|
809
|
+
node = parent
|
|
810
|
+
hops += 1
|
|
811
|
+
return False
|
|
812
|
+
|
|
813
|
+
@classmethod
|
|
814
|
+
def _is_drop_collection(cls, draw_state):
|
|
815
|
+
# Immediate owners (on_drop stamps _dnd_immediate) publish all their
|
|
816
|
+
# slots via _dnd_extra_slots - no collection value to sanity-check.
|
|
817
|
+
if getattr(draw_state, "_dnd_immediate", False):
|
|
818
|
+
return not cls._inside_dragged(draw_state)
|
|
819
|
+
# draw_collection by name; any other view may participate by stamping
|
|
820
|
+
# _dnd_drop_target = True on its draw_state each render (it must also
|
|
821
|
+
# maintain ds._children ordered by collection key, with each child's
|
|
822
|
+
# `key` kwarg matching - same contract draw_collection fulfills).
|
|
823
|
+
if (getattr(getattr(draw_state, "_view_func", None), "__name__", None) != "draw_collection"
|
|
824
|
+
and not getattr(draw_state, "_dnd_drop_target", False)):
|
|
825
|
+
return False
|
|
826
|
+
coll = draw_state._raw_input_value
|
|
827
|
+
if not isinstance(coll, (dict, list)):
|
|
828
|
+
return False
|
|
829
|
+
if coll is cls.value: # a dict can't be dropped into itself
|
|
830
|
+
return False
|
|
831
|
+
return not cls._inside_dragged(draw_state)
|
|
832
|
+
|
|
833
|
+
@classmethod
|
|
834
|
+
def _compute_slots(cls, mx, my):
|
|
835
|
+
meltygui = Core.melty
|
|
836
|
+
slots = []
|
|
837
|
+
r = DROP_RADIUS
|
|
838
|
+
seen = set()
|
|
839
|
+
# Drop slots are chosen by proximity to the TOP EDGE of the floating
|
|
840
|
+
# dragged view, not the cursor: the insertion line always tracks where
|
|
841
|
+
# the view's own top will go, which reads far more naturally than
|
|
842
|
+
# snapping to whichever gap happens to sit under the cursor (usually
|
|
843
|
+
# mid-header, a grab-offset below the top). The view floats with its
|
|
844
|
+
# top-left at (mx - grab_offset[0], my - grab_offset[1]); we keep the
|
|
845
|
+
# probe x at the cursor (still a point on the top edge), so horizontal
|
|
846
|
+
# collection-selection and the slot-inclusion test are unchanged - only
|
|
847
|
+
# the vertical probe moves up to the view's top.
|
|
848
|
+
# Horizontal collections use the same probe: (mx, my) after the shift
|
|
849
|
+
# below is the point on the dragged view's TOP edge closest to the
|
|
850
|
+
# mouse (the cursor x always lies within the view's x-span), so
|
|
851
|
+
# vertical slot lines snap to where the cursor is along the bar, not
|
|
852
|
+
# to wherever the view's left edge happens to float.
|
|
853
|
+
my = my - cls.grab_offset[1]
|
|
854
|
+
for rid in meltygui._bvh.intersection((mx - r, my - r, mx + r, my + r)):
|
|
855
|
+
if rid in seen:
|
|
856
|
+
continue
|
|
857
|
+
seen.add(rid)
|
|
858
|
+
ds = meltygui._bvh_id_to_ds.get(rid)
|
|
859
|
+
if ds is None or ds.closed or ds.abs_closed:
|
|
860
|
+
continue
|
|
861
|
+
if cls._under_hidden_ancestor(ds):
|
|
862
|
+
continue
|
|
863
|
+
if not cls._is_drop_collection(ds):
|
|
864
|
+
continue
|
|
865
|
+
cls._collection_slots(ds, mx, my, slots)
|
|
866
|
+
slots.sort(key=lambda s: s[0])
|
|
867
|
+
cls.slots = slots
|
|
868
|
+
nearest = slots[0] if slots else None
|
|
869
|
+
# The start position competes on distance but is drawn as a dot frame
|
|
870
|
+
# (draw_home), not a slot line. It wins ties (<=) so that while the
|
|
871
|
+
# view's top still sits inside the placeholder (distance 0) it beats the
|
|
872
|
+
# gap lines hugging the placeholder edges - otherwise a barely-moved
|
|
873
|
+
# drag snaps to one of those and reorders. _HOME routes to the
|
|
874
|
+
# same "nothing there → snap back" drop path as blank space.
|
|
875
|
+
home_dist = cls._home_distance(mx, my)
|
|
876
|
+
if (home_dist is not None and home_dist <= DROP_RADIUS
|
|
877
|
+
and (nearest is None or home_dist <= nearest[0])):
|
|
878
|
+
cls.nearest = _HOME
|
|
879
|
+
else:
|
|
880
|
+
cls.nearest = nearest
|
|
881
|
+
|
|
882
|
+
@classmethod
|
|
883
|
+
def _under_hidden_ancestor(cls, ds):
|
|
884
|
+
"""True when the collection sits under an ancestor that isn't showing
|
|
885
|
+
its subtree: a COLLAPSED (or closed-closable) view anywhere up the
|
|
886
|
+
_parent chain, or a window hidden offscreen (_hidden_offscreen, the
|
|
887
|
+
spawner-scrolled-away case). abs_closed can't catch either — it only
|
|
888
|
+
hops parent_window to parent_window, so a collection nested inside a
|
|
889
|
+
collapsed plain view still read as open while its children's stale
|
|
890
|
+
BVH geometry kept contributing slot lines (the 'random lines
|
|
891
|
+
everywhere' leak). _hidden_offscreen is also honored on the ds ITSELF
|
|
892
|
+
(a view a parent stopped rendering — e.g. a deselected tab's content —
|
|
893
|
+
stamps it; see draw_collection_as_tabs). The walk stops on the root's
|
|
894
|
+
_parent self-loop."""
|
|
895
|
+
if getattr(ds, '_hidden_offscreen', False):
|
|
896
|
+
return True
|
|
897
|
+
prev, node = ds, ds._parent
|
|
898
|
+
for _ in range(64):
|
|
899
|
+
if node is None or node is prev:
|
|
900
|
+
return False
|
|
901
|
+
if not node.expanded or (node.closed and node.closable):
|
|
902
|
+
return True
|
|
903
|
+
if getattr(node, '_hidden_offscreen', False):
|
|
904
|
+
return True
|
|
905
|
+
prev, node = node, node._parent
|
|
906
|
+
return False
|
|
907
|
+
|
|
908
|
+
@classmethod
|
|
909
|
+
def _collection_slots(cls, ds, mx, my, out):
|
|
910
|
+
"""Append every slot of one collection: above its first live row,
|
|
911
|
+
between consecutive rows, and below the last (an empty/collapsed
|
|
912
|
+
collection gets a single append-at-end slot under its header).
|
|
913
|
+
Horizontal collections (horizontal=True kwarg, or _dnd_horizontal
|
|
914
|
+
stamped on the ds) get vertical insertion lines instead: one at each
|
|
915
|
+
child's left edge plus one after the last child, anchored per-child so
|
|
916
|
+
wrapped rows just work."""
|
|
917
|
+
coll = ds._raw_input_value
|
|
918
|
+
clip = ds.abs_clip_rect
|
|
919
|
+
if clip is None:
|
|
920
|
+
return
|
|
921
|
+
cl, ct, cr, cb = clip
|
|
922
|
+
# A collection's clip can be its full CONTENT box (content heights of
|
|
923
|
+
# tens of thousands of lines past the window), and rows that are
|
|
924
|
+
# themselves collapsed collections have content-height bboxes too -
|
|
925
|
+
# midpoints between such rows land well of the window. Clamp the
|
|
926
|
+
# slot band to the enclosing window's rect and the display so we
|
|
927
|
+
# never draw outside what's actually visible.
|
|
928
|
+
pw = ds.parent_window
|
|
929
|
+
if (pw is not None and pw is not ds and pw.abs_left is not None
|
|
930
|
+
and pw.abs_top is not None and pw.width and pw.height):
|
|
931
|
+
cl = max(cl, pw.abs_left)
|
|
932
|
+
ct = max(ct, pw.abs_top)
|
|
933
|
+
cr = min(cr, pw.abs_left + pw.width)
|
|
934
|
+
cb = min(cb, pw.abs_top + pw.height)
|
|
935
|
+
disp = imgui.get_io().display_size
|
|
936
|
+
cl, ct = max(cl, 0), max(ct, 0)
|
|
937
|
+
cr, cb = min(cr, disp[0]), min(cb, disp[1])
|
|
938
|
+
if cr <= cl or cb <= ct:
|
|
939
|
+
return
|
|
940
|
+
|
|
941
|
+
# Owner-painted slots: a view whose visual gaps the framework can't
|
|
942
|
+
# derive from _children (e.g. draw_collection_as_tabs, whose _children
|
|
943
|
+
# are the tab BUTTONS while the open tabs' content stacks vertically)
|
|
944
|
+
# refreshes ds._dnd_extra_slots each render - entries
|
|
945
|
+
# (e_idx, a0, a1, cross, vertical), same geometry the slot tuple
|
|
946
|
+
# carries. They go through the same radius/occlude/band gauntlet and
|
|
947
|
+
# simply compete by distance alongside the _children-derived slots.
|
|
948
|
+
extra = getattr(ds, "_dnd_extra_slots", None)
|
|
949
|
+
if extra:
|
|
950
|
+
for e_idx, a0, a1, cross, vert in extra:
|
|
951
|
+
if vert:
|
|
952
|
+
v0, v1 = max(ct, a0), min(cb, a1)
|
|
953
|
+
if v1 > v0:
|
|
954
|
+
cls._add_vslot(out, ds, e_idx, v0, v1, cross,
|
|
955
|
+
cl - 6, cr + 6, mx, my)
|
|
956
|
+
else:
|
|
957
|
+
h0, h1 = max(cl, a0), min(cr, a1)
|
|
958
|
+
if h1 > h0:
|
|
959
|
+
cls._add_slot(out, ds, e_idx, h0, h1, cross,
|
|
960
|
+
ct - 6, cb + 6, mx, my)
|
|
961
|
+
|
|
962
|
+
# Immediate views have NO _children/_raw_input_value collection to
|
|
963
|
+
# derive rows from - their extra slots above are ALL their slots.
|
|
964
|
+
if getattr(ds, "_dnd_immediate", False):
|
|
965
|
+
return
|
|
966
|
+
|
|
967
|
+
if isinstance(coll, dict):
|
|
968
|
+
keys = list(coll.keys())
|
|
969
|
+
else:
|
|
970
|
+
keys = None # list has positional keys, idx is the key
|
|
971
|
+
|
|
972
|
+
rows = []
|
|
973
|
+
for idx, child in (ds._children or {}).items():
|
|
974
|
+
if child is None or child is cls.item_ds:
|
|
975
|
+
continue
|
|
976
|
+
if child._collection_draw_state is not ds:
|
|
977
|
+
continue
|
|
978
|
+
# Deliberately NOT abs_closed (it counts a row's OWN collapsed
|
|
979
|
+
# state - collapsed headers are visible and must keep slots) and
|
|
980
|
+
# NOT _bvh_bbox liveness (bvh_query lazily EVICTS collapsed rows'
|
|
981
|
+
# boxes, so any unrelated query over one would drop it from slot
|
|
982
|
+
# math until the next collection re-render). Rows hidden by a
|
|
983
|
+
# collapsed/closed ancestor never get here: the collection itself
|
|
984
|
+
# is filtered as a query by its own abs_closed. Stale rows are
|
|
985
|
+
# filtered by the key-at-idx ghost guard and the clip rect clamp.
|
|
986
|
+
if child.closed:
|
|
987
|
+
continue
|
|
988
|
+
# Ghost guard: a child whose key left the collection never
|
|
989
|
+
# re-renders, so its stale box would otherwise still make slots.
|
|
990
|
+
if keys is not None:
|
|
991
|
+
if idx >= len(keys):
|
|
992
|
+
continue
|
|
993
|
+
child_key = (child._kwargs or {}).get("key", None)
|
|
994
|
+
if child_key != keys[idx]:
|
|
995
|
+
continue
|
|
996
|
+
elif idx >= len(coll):
|
|
997
|
+
continue
|
|
998
|
+
top = child.abs_top
|
|
999
|
+
if top is None:
|
|
1000
|
+
continue
|
|
1001
|
+
# Visible height, not stored height: a collapsed view occupies
|
|
1002
|
+
# only its header band, whatever its (possibly stale, expanded)
|
|
1003
|
+
# height claims. Only the final slot below the last row actually
|
|
1004
|
+
# uses this - every other slot anchors purely on abs_top.
|
|
1005
|
+
visible_h = child.height or 0
|
|
1006
|
+
if not child.expanded:
|
|
1007
|
+
visible_h = min(visible_h, child.header_height or visible_h)
|
|
1008
|
+
left = child.abs_left
|
|
1009
|
+
right = (left + (child.width or 0)) if left is not None else None
|
|
1010
|
+
rows.append((idx, top, top + visible_h, left, right))
|
|
1011
|
+
rows.sort(key=lambda r: (r[1], r[0]))
|
|
1012
|
+
|
|
1013
|
+
horizontal = bool((ds._kwargs or {}).get("horizontal")) or getattr(ds, "_dnd_horizontal", False)
|
|
1014
|
+
if horizontal and rows:
|
|
1015
|
+
# Reading order: row y first, then x within it - the append
|
|
1016
|
+
# slot must sit after the visually last child, not the max index.
|
|
1017
|
+
rows.sort(key=lambda r: (r[1], r[3] if r[3] is not None else 0))
|
|
1018
|
+
x_min, x_max = cl - 6, cr + 6
|
|
1019
|
+
for idx, top, bottom, left, _right in rows:
|
|
1020
|
+
if left is None:
|
|
1021
|
+
continue
|
|
1022
|
+
y0, y1 = max(ct, top), min(cb, bottom)
|
|
1023
|
+
if y1 <= y0:
|
|
1024
|
+
continue
|
|
1025
|
+
cls._add_vslot(out, ds, idx, y0, y1, left - 2, x_min, x_max,
|
|
1026
|
+
mx, my)
|
|
1027
|
+
last_idx, top, bottom, _left, right = rows[-1]
|
|
1028
|
+
y0, y1 = max(ct, top), min(cb, bottom)
|
|
1029
|
+
if right is not None and y1 > y0:
|
|
1030
|
+
cls._add_vslot(out, ds, last_idx + 1, y0, y1, right + 3,
|
|
1031
|
+
x_min, x_max, mx, my)
|
|
1032
|
+
return
|
|
1033
|
+
|
|
1034
|
+
# Indent each slot line to where this collection's rows actually sit so
|
|
1035
|
+
# the line's left edge tracks the content indent - a nested collection's
|
|
1036
|
+
# lines read as visibly deeper at a glance, instead of every collection
|
|
1037
|
+
# drawing its lines flush at the same static collection offset. A row's
|
|
1038
|
+
# abs_left always carries the accumulated indent_size of every enclosing
|
|
1039
|
+
# collection; `content_left` (the collection's own indent_size off its
|
|
1040
|
+
# left) is the fallback for a missing row left or the collapsed collection.
|
|
1041
|
+
indent = ds._kwargs.get("indent_size", 0) or 0
|
|
1042
|
+
content_left = ds.abs_left + indent
|
|
1043
|
+
x1 = min(cr, ds.abs_left + (ds.width or 0) - 4)
|
|
1044
|
+
y_min, y_max = ct - 6, cb + 6
|
|
1045
|
+
|
|
1046
|
+
def _slot_x0(row_left):
|
|
1047
|
+
return max(cl, row_left if row_left is not None else content_left)
|
|
1048
|
+
|
|
1049
|
+
if not rows:
|
|
1050
|
+
x0 = _slot_x0(None)
|
|
1051
|
+
if x1 <= x0:
|
|
1052
|
+
return
|
|
1053
|
+
y = ds.abs_top + (ds.header_height or 0) + 4
|
|
1054
|
+
cls._add_slot(out, ds, len(coll), x0, x1, y, y_min, y_max, mx, my)
|
|
1055
|
+
return
|
|
1056
|
+
|
|
1057
|
+
# One "insert before me" slot per row, anchored on the row's live
|
|
1058
|
+
# abs_top - an exact screen coordinate regardless of how tall (or
|
|
1059
|
+
# collapsed) the rows above it are. Heights only ever matter to
|
|
1060
|
+
# the single append-at-end slot under the last row.
|
|
1061
|
+
for idx, top, _bottom, left, _right in rows:
|
|
1062
|
+
x0 = _slot_x0(left)
|
|
1063
|
+
if x1 <= x0:
|
|
1064
|
+
continue
|
|
1065
|
+
cls._add_slot(out, ds, idx, x0, x1, top - 2, y_min, y_max, mx, my)
|
|
1066
|
+
last_idx, _top, last_bottom, last_left, _last_right = rows[-1]
|
|
1067
|
+
x0 = _slot_x0(last_left)
|
|
1068
|
+
if x1 > x0:
|
|
1069
|
+
cls._add_slot(out, ds, last_idx + 1, x0, x1, last_bottom + 3,
|
|
1070
|
+
y_min, y_max, mx, my)
|
|
1071
|
+
|
|
1072
|
+
# Slot tuple format (shared by _draw_slots/_commit):
|
|
1073
|
+
# (dist, a0, a1, cross, ds, insert_idx, vertical)
|
|
1074
|
+
# horizontal line: a0..a1 = x span at y=cross; vertical: a0..a1 = y span
|
|
1075
|
+
# at x=cross.
|
|
1076
|
+
|
|
1077
|
+
@classmethod
|
|
1078
|
+
def _add_slot(cls, out, ds, insert_idx, x0, x1, y, y_min, y_max, mx, my):
|
|
1079
|
+
# (mx, my) is the test point: mx the cursor x, my the dragged window's
|
|
1080
|
+
# TOP edge (not the cursor y) - see _compute_slots.
|
|
1081
|
+
if y < y_min or y > y_max:
|
|
1082
|
+
return
|
|
1083
|
+
dx = max(x0 - mx, 0.0, mx - x1)
|
|
1084
|
+
dist = math.hypot(dx, my - y)
|
|
1085
|
+
if dist > DROP_RADIUS:
|
|
1086
|
+
return
|
|
1087
|
+
# A slot covered by a window stacked above its own window is neither
|
|
1088
|
+
# visible nor droppable. Use the point on the line the distance was
|
|
1089
|
+
# measured from (nearest to the cursor) - BVH point query, cheap and
|
|
1090
|
+
# accurate, handles half-covered windows per usual.
|
|
1091
|
+
if cls._slot_occluded(ds, min(max(mx, x0), x1), y):
|
|
1092
|
+
return
|
|
1093
|
+
out.append((dist, x0, x1, y, ds, insert_idx, False))
|
|
1094
|
+
|
|
1095
|
+
@classmethod
|
|
1096
|
+
def _add_vslot(cls, out, ds, insert_idx, y0, y1, x, x_min, x_max, mx, my):
|
|
1097
|
+
"""Vertical insertion line (horizontal collections). Probe: (mx, my)
|
|
1098
|
+
is the point on the dragged view's top edge closest to the mouse —
|
|
1099
|
+
cursor x, view top for y (see _compute_slots) — so the nearest slot
|
|
1100
|
+
follows the cursor along the bar."""
|
|
1101
|
+
if x < x_min or x > x_max:
|
|
1102
|
+
return
|
|
1103
|
+
dy = max(y0 - my, 0.0, my - y1)
|
|
1104
|
+
dist = math.hypot(mx - x, dy)
|
|
1105
|
+
if dist > DROP_RADIUS:
|
|
1106
|
+
return
|
|
1107
|
+
if cls._slot_occluded(ds, x, min(max(my, y0), y1)):
|
|
1108
|
+
return
|
|
1109
|
+
out.append((dist, y0, y1, x, ds, insert_idx, True))
|
|
1110
|
+
|
|
1111
|
+
@classmethod
|
|
1112
|
+
def _slot_occluded(cls, coll_ds, x, y):
|
|
1113
|
+
"""True when the topmost closable window at (x, y) isn't one of the
|
|
1114
|
+
slot collection's own ancestor windows — i.e. the slot lies behind
|
|
1115
|
+
another window there. The floating dragged window (and anything in
|
|
1116
|
+
it) never occludes: the slot under the user's hand is the one they
|
|
1117
|
+
most want."""
|
|
1118
|
+
# Seed with the collection ds itself: an immediate owner (e.g. the
|
|
1119
|
+
# code editor's tabs) IS its own window, and a top-level window's
|
|
1120
|
+
# parent_window is None - starting the walk one step up would leave
|
|
1121
|
+
# owners empty and the window would occlude its own slots.
|
|
1122
|
+
owners = {id(coll_ds)}
|
|
1123
|
+
win = coll_ds.parent_window
|
|
1124
|
+
hops = 0
|
|
1125
|
+
while win is not None and hops < 32:
|
|
1126
|
+
owners.add(id(win))
|
|
1127
|
+
nxt = win.parent_window
|
|
1128
|
+
if nxt is win:
|
|
1129
|
+
break
|
|
1130
|
+
win = nxt
|
|
1131
|
+
hops += 1
|
|
1132
|
+
for hit in Core.melty.bvh_query(x, y):
|
|
1133
|
+
if not hit.closable:
|
|
1134
|
+
continue
|
|
1135
|
+
if cls._inside_dragged(hit):
|
|
1136
|
+
continue
|
|
1137
|
+
return id(hit) not in owners
|
|
1138
|
+
return False
|
|
1139
|
+
|
|
1140
|
+
@classmethod
|
|
1141
|
+
def _draw_slots(cls):
|
|
1142
|
+
if not cls.slots:
|
|
1143
|
+
return
|
|
1144
|
+
meltygui = Core.melty
|
|
1145
|
+
overlay = imgui.get_overlay_draw_list()
|
|
1146
|
+
if meltygui._overlay_channels_active:
|
|
1147
|
+
# The overlay list is split into max_layer channels; max_layer - 1
|
|
1148
|
+
# is the global top channel, the only one the split renderer never
|
|
1149
|
+
# stencil-masks on higher windows (window_index can pick any
|
|
1150
|
+
# mid channel, which is why the lines vanished on busy windows).
|
|
1151
|
+
overlay.channels_set_current(meltygui.max_layer - 5)
|
|
1152
|
+
# The lines ride the global top overlay channel (never stencil-masked),
|
|
1153
|
+
# which would also draw them over the floating dragged window: carve
|
|
1154
|
+
# its rect out of every line by hand. Derive the rect from the live
|
|
1155
|
+
# mouse (where the glue puts the window this frame), not the
|
|
1156
|
+
# draw_state, which is a frame behind during fast motion.
|
|
1157
|
+
mx, my = imgui.get_io().mouse_pos
|
|
1158
|
+
w, h = cls.size
|
|
1159
|
+
ix0, iy0 = mx - cls.grab_offset[0], my - cls.grab_offset[1]
|
|
1160
|
+
ix1, iy1 = ix0 + (w or 0), iy0 + (h or 0)
|
|
1161
|
+
|
|
1162
|
+
for slot in cls.slots:
|
|
1163
|
+
dist, a0, a1, cross, _ds, _idx, vert = slot
|
|
1164
|
+
nearest = slot is cls.nearest
|
|
1165
|
+
# Color each line from its own collection's stashed tint - the
|
|
1166
|
+
# same brightened-tint helper the swoosh and selection highlights
|
|
1167
|
+
# use, so slots read as part of the window they'd drop into.
|
|
1168
|
+
rgb = meltygui._highlight_rgb(_ds.current_tint)
|
|
1169
|
+
# Opacity: nearest = the active drop zone at full strength (it
|
|
1170
|
+
# snaps between lines); the rest ease in from zero at the drag
|
|
1171
|
+
# edge; everything rides the pickup reveal (slot_alpha).
|
|
1172
|
+
col = pack_color(*rgb, cls.slot_alpha(dist, nearest))
|
|
1173
|
+
thickness = 3.0 if nearest else 2.0
|
|
1174
|
+
if vert:
|
|
1175
|
+
# Vertical insertion line at x=cross spanning y a0..a1
|
|
1176
|
+
# (horizontal collections). Same carve-out around the floating
|
|
1177
|
+
# dragged window, axes swapped.
|
|
1178
|
+
x, y0, y1 = cross, a0, a1
|
|
1179
|
+
if ix0 - 2.0 <= x <= ix1 + 2.0:
|
|
1180
|
+
if iy0 > y0:
|
|
1181
|
+
overlay.add_line(x, y0, x, min(y1, iy0), col, thickness)
|
|
1182
|
+
if iy1 < y1:
|
|
1183
|
+
overlay.add_line(x, max(y0, iy1), x, y1, col, thickness)
|
|
1184
|
+
else:
|
|
1185
|
+
overlay.add_line(x, y0, x, y1, col, thickness)
|
|
1186
|
+
if nearest:
|
|
1187
|
+
if not (iy0 <= y0 <= iy1 and ix0 - 4 <= x <= ix1 + 4):
|
|
1188
|
+
overlay.add_circle_filled(x, y0, 3.5, col)
|
|
1189
|
+
if not (iy0 <= y1 <= iy1 and ix0 - 4 <= x <= ix1 + 4):
|
|
1190
|
+
overlay.add_circle_filled(x, y1, 3.5, col)
|
|
1191
|
+
continue
|
|
1192
|
+
x0, x1, y = a0, a1, cross
|
|
1193
|
+
if iy0 - 2.0 <= y <= iy1 + 2.0:
|
|
1194
|
+
# Line crosses the window's band: keep the spans beside it.
|
|
1195
|
+
if ix0 > x0:
|
|
1196
|
+
overlay.add_line(x0, y, min(x1, ix0), y, col, thickness)
|
|
1197
|
+
if ix1 < x1:
|
|
1198
|
+
overlay.add_line(max(x0, ix1), y, x1, y, col, thickness)
|
|
1199
|
+
else:
|
|
1200
|
+
overlay.add_line(x0, y, x1, y, col, thickness)
|
|
1201
|
+
if nearest:
|
|
1202
|
+
if not (ix0 <= x0 <= ix1 and iy0 - 4 <= y <= iy1 + 4):
|
|
1203
|
+
overlay.add_circle_filled(x0, y, 3.5, col)
|
|
1204
|
+
if not (ix0 <= x1 <= ix1 and iy0 - 4 <= y <= iy1 + 4):
|
|
1205
|
+
overlay.add_circle_filled(x1, y, 3.5, col)
|
|
1206
|
+
|
|
1207
|
+
@classmethod
|
|
1208
|
+
def _home_distance(cls, mx, my):
|
|
1209
|
+
"""Distance from the probe point (mx, my) — the dragged view's top
|
|
1210
|
+
edge, see _compute_slots — to the start drop zone, the item's pickup
|
|
1211
|
+
slot (the placeholder). 0 anywhere inside the rect, so the whole
|
|
1212
|
+
original footprint reads as "drop back here". None when there's no
|
|
1213
|
+
captured home rect/size to measure against."""
|
|
1214
|
+
if cls.home_rect is None:
|
|
1215
|
+
return None
|
|
1216
|
+
w, h = cls.size
|
|
1217
|
+
if not w or not h:
|
|
1218
|
+
return None
|
|
1219
|
+
x, y = cls.home_rect
|
|
1220
|
+
dx = max(x - mx, 0.0, mx - (x + w))
|
|
1221
|
+
dy = max(y - my, 0.0, my - (y + h))
|
|
1222
|
+
return math.hypot(dx, dy)
|
|
1223
|
+
|
|
1224
|
+
@classmethod
|
|
1225
|
+
def _draw_home(cls):
|
|
1226
|
+
"""Frame the start slot so it reads as a droppable target: a faint
|
|
1227
|
+
rect while dragging anywhere, lifted (but kept subtle — this is a
|
|
1228
|
+
cancel zone, not a reorder target) when the cursor is over it
|
|
1229
|
+
(cls.nearest is _HOME). Rides the same top overlay channel as the slot
|
|
1230
|
+
lines — which is ABOVE the floating dragged window (it's on the top
|
|
1231
|
+
layer), so the frame would paint over the dragged view on a short
|
|
1232
|
+
drag. The frame is aligned to the home socket's own background rect
|
|
1233
|
+
(see draw_placeholder / draw_home_blank — left=x, top=y, width=w,
|
|
1234
|
+
height=h-2, rounding=5.0). To keep it off the dragged view without
|
|
1235
|
+
losing the rounded corners, draw the SAME rounded rect clipped to the
|
|
1236
|
+
strips around the window's live rect (top & bottom full-width, left &
|
|
1237
|
+
right middle-band only) — the corners live in the full-width top/bottom
|
|
1238
|
+
strips, so they survive; the strips don't overlap, so the
|
|
1239
|
+
semi-transparent outline never double-draws at a seam."""
|
|
1240
|
+
if cls.home_rect is None:
|
|
1241
|
+
return
|
|
1242
|
+
w, h = cls.size
|
|
1243
|
+
if not w or not h:
|
|
1244
|
+
return
|
|
1245
|
+
meltygui = Core.melty
|
|
1246
|
+
overlay = imgui.get_overlay_draw_list()
|
|
1247
|
+
if meltygui._overlay_channels_active:
|
|
1248
|
+
overlay.channels_set_current(meltygui.max_layer - 5)
|
|
1249
|
+
x, y = cls.home_rect
|
|
1250
|
+
src = cls.source_ds
|
|
1251
|
+
rgb = meltygui._highlight_rgb(src.current_tint) if src is not None else (1.0, 1.0, 1.0)
|
|
1252
|
+
active = cls.nearest is _HOME
|
|
1253
|
+
col = pack_color(*rgb, cls.home_alpha(active))
|
|
1254
|
+
thickness = 1.75 if active else 1.5
|
|
1255
|
+
rounding = 5.0
|
|
1256
|
+
# Inset 1px on every side so the highlight sits ever so slightly
|
|
1257
|
+
# inside the socket background rect (x, y, w, h-2).
|
|
1258
|
+
fx0, fy0, fx1, fy1 = x + 1, y + 1, x + w - 1, y + h - 3
|
|
1259
|
+
|
|
1260
|
+
# The floating item window's live rect - same source as _draw_slots
|
|
1261
|
+
# (the mouse, where the glue puts the window this frame, a frame ahead
|
|
1262
|
+
# of the draw_state during fast motion).
|
|
1263
|
+
mx, my = imgui.get_io().mouse_pos
|
|
1264
|
+
ix0, iy0 = mx - cls.grab_offset[0], my - cls.grab_offset[1]
|
|
1265
|
+
ix1, iy1 = ix0 + w, iy0 + h
|
|
1266
|
+
|
|
1267
|
+
if fx1 <= ix0 or fx0 >= ix1 or fy1 <= iy0 or fy0 >= iy1:
|
|
1268
|
+
# No overlap with the dragged view: draw the rounded frame whole.
|
|
1269
|
+
overlay.add_rect(fx0, fy0, fx1, fy1, col, rounding=rounding,
|
|
1270
|
+
thickness=thickness)
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
# Overlap: clip the rounded frame to the non-overlapping strips that
|
|
1274
|
+
# make the frame minus the live rect, redrawing the whole rounded
|
|
1275
|
+
# rect on each so its corners stay round wherever they're not over the
|
|
1276
|
+
# dragged view.
|
|
1277
|
+
band_t, band_b = max(fy0, iy0), min(fy1, iy1)
|
|
1278
|
+
strips = (
|
|
1279
|
+
(fx0, fy0, fx1, iy0), # top (full width - holds top corners)
|
|
1280
|
+
(fx0, iy1, fx1, fy1), # bottom (full width - holds bottom corners)
|
|
1281
|
+
(fx0, band_t, ix0, band_b), # left middle band
|
|
1282
|
+
(ix1, band_t, fx1, band_b), # right middle band
|
|
1283
|
+
)
|
|
1284
|
+
for cx0, cy0, cx1, cy1 in strips:
|
|
1285
|
+
if cx1 <= cx0 or cy1 <= cy0:
|
|
1286
|
+
continue
|
|
1287
|
+
overlay.push_clip_rect(cx0, cy0, cx1, cy1, True)
|
|
1288
|
+
overlay.add_rect(fx0, fy0, fx1, fy1, col, rounding=rounding,
|
|
1289
|
+
thickness=thickness)
|
|
1290
|
+
overlay.pop_clip_rect()
|
|
1291
|
+
|
|
1292
|
+
@classmethod
|
|
1293
|
+
def _commit(cls):
|
|
1294
|
+
"""Register the reorder with Melty.dnd_requests — core_render's
|
|
1295
|
+
wrapper tail intercepts the target draw_state's next return and
|
|
1296
|
+
reports (True, reordered_collection), undo-manager style."""
|
|
1297
|
+
meltygui = Core.melty
|
|
1298
|
+
_dist, _a0, _a1, _cross, target_ds, insert_idx, _vert = cls.nearest
|
|
1299
|
+
src_ds, key = cls.source_ds, cls.key
|
|
1300
|
+
target_im = getattr(target_ds, "_dnd_immediate", False)
|
|
1301
|
+
|
|
1302
|
+
if target_ds is src_ds:
|
|
1303
|
+
if cls.immediate:
|
|
1304
|
+
cls._queue_drop(src_ds, DropEvent("reorder", key, cls.value,
|
|
1305
|
+
cls.im_index, insert_idx))
|
|
1306
|
+
else:
|
|
1307
|
+
meltygui.dnd_requests[src_ds] = Reorder(key, insert_idx)
|
|
1308
|
+
cls._wake(src_ds)
|
|
1309
|
+
else:
|
|
1310
|
+
if cls.immediate:
|
|
1311
|
+
value = cls.value
|
|
1312
|
+
cls._queue_drop(src_ds, DropEvent("remove", key, value,
|
|
1313
|
+
cls.im_index))
|
|
1314
|
+
else:
|
|
1315
|
+
src = src_ds._raw_input_value
|
|
1316
|
+
if isinstance(src, dict):
|
|
1317
|
+
if key not in src:
|
|
1318
|
+
return
|
|
1319
|
+
value = src[key]
|
|
1320
|
+
elif isinstance(src, list):
|
|
1321
|
+
if not (isinstance(key, int) and 0 <= key < len(src)):
|
|
1322
|
+
return
|
|
1323
|
+
value = src[key]
|
|
1324
|
+
else:
|
|
1325
|
+
return
|
|
1326
|
+
meltygui.dnd_requests[src_ds] = Remove(key)
|
|
1327
|
+
cls._wake(src_ds)
|
|
1328
|
+
if target_im:
|
|
1329
|
+
cls._queue_drop(target_ds, DropEvent("insert", key, value,
|
|
1330
|
+
insert_index=insert_idx))
|
|
1331
|
+
else:
|
|
1332
|
+
meltygui.dnd_requests[target_ds] = Insert(key, value, insert_idx)
|
|
1333
|
+
cls._wake(target_ds)
|
|
1334
|
+
request_render()
|
|
1335
|
+
|
|
1336
|
+
@classmethod
|
|
1337
|
+
def _reset(cls):
|
|
1338
|
+
src, item = cls.source_ds, cls.item_ds
|
|
1339
|
+
cls.active = False
|
|
1340
|
+
cls.immediate = False
|
|
1341
|
+
cls.im_index = None
|
|
1342
|
+
cls.item_ds = None
|
|
1343
|
+
cls.source_ds = None
|
|
1344
|
+
cls.key = None
|
|
1345
|
+
cls.value = None
|
|
1346
|
+
cls.home_rect = None
|
|
1347
|
+
Core.melty.dnd_home_rect = None
|
|
1348
|
+
cls.slots = ()
|
|
1349
|
+
cls.nearest = None
|
|
1350
|
+
cls.travel = 0.0
|
|
1351
|
+
cls._last_mouse = None
|
|
1352
|
+
if item is not None:
|
|
1353
|
+
item.window_pos = (0, 0)
|
|
1354
|
+
# Undo the floating-render override. dragged_item_kwargs() forced
|
|
1355
|
+
# auto_resize=False + a fixed width so the item floated at its
|
|
1356
|
+
# pickup size; core_render's `fixed_size = not draw_state.auto_resize`
|
|
1357
|
+
# makes that False sticky, so once re-homed the item would stay
|
|
1358
|
+
# frozen at the pickup width instead of scaling to fill its new
|
|
1359
|
+
# container. Restore auto-resize so it re-derives its size in place.
|
|
1360
|
+
item.auto_resize = True
|
|
1361
|
+
cls._wake(item)
|
|
1362
|
+
if src is not None:
|
|
1363
|
+
cls._wake(src)
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
# ─── collection mutations ─────────────────────────────────────────────────
|
|
1367
|
+
# Reversible in-place edits. These are what Melty.dnd_requests carries and -
|
|
1368
|
+
# crucially - what lands on the undo stack: a Change pair (old=inverse,
|
|
1369
|
+
# new=mutation) instead of value snapshots, so undoing a drag never copies a
|
|
1370
|
+
# dict ("insert x at key a", "remove y at key b"). apply() mutates the LIVE
|
|
1371
|
+
# dict/list at interception frame and returns (changed, coll, inverse), the
|
|
1372
|
+
# inverse computed against the pre-apply state. UndoManager.undo()/redo()
|
|
1373
|
+
# route either side through Melty.undo_requests and the wrapper tail applies
|
|
1374
|
+
# it - the same (changed, value) return path as any user edit. Identity is
|
|
1375
|
+
# checked by duck type (__collection_mutation__) in core_render and
|
|
1376
|
+
# core_undo so neither needs an import from here.
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _free_key(coll, key):
|
|
1380
|
+
"""A key that collides with nothing in `coll` BY STRING — draw_state
|
|
1381
|
+
uniques are built from stringified keys, so an int 4 dropped beside an
|
|
1382
|
+
existing '4' would give two rows the same identity (shared draw_state,
|
|
1383
|
+
heights fighting every frame). Compare str-to-str, not just `in`."""
|
|
1384
|
+
taken = {str(k) for k in coll}
|
|
1385
|
+
if str(key) not in taken:
|
|
1386
|
+
return key
|
|
1387
|
+
base = str(key)
|
|
1388
|
+
n = 2
|
|
1389
|
+
while f"{base}_{n}" in taken:
|
|
1390
|
+
n += 1
|
|
1391
|
+
return f"{base}_{n}"
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
class CollectionMutation:
|
|
1395
|
+
__collection_mutation__ = True
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
@dataclass(frozen=True)
|
|
1399
|
+
class Reorder(CollectionMutation):
|
|
1400
|
+
"""Move existing `key` to before the element currently at `insert_idx`
|
|
1401
|
+
(pre-removal coordinates). For lists `key` is the from-index."""
|
|
1402
|
+
key: object
|
|
1403
|
+
insert_idx: int
|
|
1404
|
+
|
|
1405
|
+
def apply(self, coll):
|
|
1406
|
+
if isinstance(coll, dict):
|
|
1407
|
+
items = list(coll.items())
|
|
1408
|
+
cur = next((i for i, (k, _) in enumerate(items) if k == self.key), None)
|
|
1409
|
+
if cur is None:
|
|
1410
|
+
return False, coll, None
|
|
1411
|
+
pair = items.pop(cur)
|
|
1412
|
+
idx = self.insert_idx - 1 if cur < self.insert_idx else self.insert_idx
|
|
1413
|
+
idx = max(0, min(idx, len(items)))
|
|
1414
|
+
items.insert(idx, pair)
|
|
1415
|
+
if idx == cur:
|
|
1416
|
+
return False, coll, None # dropped back where it was
|
|
1417
|
+
# A collection whose order LIVES elsewhere (ParamProxy: the user's
|
|
1418
|
+
# code sources) takes the complete new key order through its
|
|
1419
|
+
# reorder_keys hook instead of the in-place clear/update. The
|
|
1420
|
+
# inverse is the complete PRE-drop order (ReorderKeys), not a
|
|
1421
|
+
# positional Reorder: the hook may have moved only some of the
|
|
1422
|
+
# stores (a comment, while the signature wasn't parsed yet), in
|
|
1423
|
+
# which case the collection's own order is unchanged and a
|
|
1424
|
+
# positional inverse would be a no-op that leaves those stores
|
|
1425
|
+
# reordered.
|
|
1426
|
+
hook = getattr(coll, "reorder_keys", None)
|
|
1427
|
+
if callable(hook):
|
|
1428
|
+
before = [k for k in dict.keys(coll)]
|
|
1429
|
+
if not hook([k for k, _ in items]):
|
|
1430
|
+
return False, coll, None
|
|
1431
|
+
return True, coll, ReorderKeys(before)
|
|
1432
|
+
coll.clear()
|
|
1433
|
+
coll.update(items)
|
|
1434
|
+
return True, coll, Reorder(self.key, cur if cur < idx else cur + 1)
|
|
1435
|
+
if isinstance(coll, list):
|
|
1436
|
+
if not (isinstance(self.key, int) and 0 <= self.key < len(coll)):
|
|
1437
|
+
return False, coll, None
|
|
1438
|
+
value = coll.pop(self.key)
|
|
1439
|
+
idx = self.insert_idx - 1 if self.key < self.insert_idx else self.insert_idx
|
|
1440
|
+
idx = max(0, min(idx, len(coll)))
|
|
1441
|
+
coll.insert(idx, value)
|
|
1442
|
+
if idx == self.key:
|
|
1443
|
+
return False, coll, None
|
|
1444
|
+
return True, coll, Reorder(idx, self.key if self.key < idx else self.key + 1)
|
|
1445
|
+
return False, coll, None
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
@dataclass(frozen=True)
|
|
1449
|
+
class ReorderKeys(CollectionMutation):
|
|
1450
|
+
"""Put a dict's keys in `keys` order — the COMPLETE order, as the
|
|
1451
|
+
reorder_keys hook takes it (the inverse a hook-owned Reorder records).
|
|
1452
|
+
Through the hook when the collection has one, else a plain in-place
|
|
1453
|
+
permutation; keys the order doesn't name keep their slots."""
|
|
1454
|
+
keys: tuple
|
|
1455
|
+
|
|
1456
|
+
def __init__(self, keys):
|
|
1457
|
+
object.__setattr__(self, "keys", tuple(keys))
|
|
1458
|
+
|
|
1459
|
+
def apply(self, coll):
|
|
1460
|
+
if not isinstance(coll, dict):
|
|
1461
|
+
return False, coll, None
|
|
1462
|
+
before = [k for k in dict.keys(coll)]
|
|
1463
|
+
hook = getattr(coll, "reorder_keys", None)
|
|
1464
|
+
if callable(hook):
|
|
1465
|
+
if not hook(list(self.keys)):
|
|
1466
|
+
return False, coll, None
|
|
1467
|
+
return True, coll, ReorderKeys(before)
|
|
1468
|
+
order = {k: i for i, k in enumerate(self.keys)}
|
|
1469
|
+
present = [k for k in before if k in order]
|
|
1470
|
+
wanted = sorted(present, key=order.__getitem__)
|
|
1471
|
+
if wanted == present:
|
|
1472
|
+
return False, coll, None
|
|
1473
|
+
refill = iter(wanted)
|
|
1474
|
+
items = [((nk := next(refill)), coll[nk]) if k in order else (k, coll[k])
|
|
1475
|
+
for k in before]
|
|
1476
|
+
coll.clear()
|
|
1477
|
+
coll.update(items)
|
|
1478
|
+
return True, coll, ReorderKeys(before)
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
@dataclass(frozen=True)
|
|
1482
|
+
class Insert(CollectionMutation):
|
|
1483
|
+
"""Insert `value` at `key` before index `insert_idx`. The key is renamed
|
|
1484
|
+
on collision; the returned inverse removes the key actually used. The
|
|
1485
|
+
value rides by reference — never a copy."""
|
|
1486
|
+
key: object
|
|
1487
|
+
value: object
|
|
1488
|
+
insert_idx: int
|
|
1489
|
+
|
|
1490
|
+
def apply(self, coll):
|
|
1491
|
+
if isinstance(coll, dict):
|
|
1492
|
+
# Dicts get string keys: a list index (int) dropped into a dict
|
|
1493
|
+
# would otherwise sit invisibly beside its stringified twin.
|
|
1494
|
+
key = self.key if isinstance(self.key, str) else str(self.key)
|
|
1495
|
+
key = _free_key(coll, key)
|
|
1496
|
+
items = list(coll.items())
|
|
1497
|
+
idx = max(0, min(self.insert_idx, len(items)))
|
|
1498
|
+
items.insert(idx, (key, self.value))
|
|
1499
|
+
coll.clear()
|
|
1500
|
+
coll.update(items)
|
|
1501
|
+
return True, coll, Remove(key)
|
|
1502
|
+
if isinstance(coll, list):
|
|
1503
|
+
idx = max(0, min(self.insert_idx, len(coll)))
|
|
1504
|
+
coll.insert(idx, self.value)
|
|
1505
|
+
return True, coll, Remove(idx)
|
|
1506
|
+
return False, coll, None
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
@dataclass(frozen=True)
|
|
1510
|
+
class Remove(CollectionMutation):
|
|
1511
|
+
key: object
|
|
1512
|
+
|
|
1513
|
+
def apply(self, coll):
|
|
1514
|
+
if isinstance(coll, dict):
|
|
1515
|
+
if self.key not in coll:
|
|
1516
|
+
return False, coll, None
|
|
1517
|
+
idx = next(i for i, k in enumerate(coll) if k == self.key)
|
|
1518
|
+
value = coll.pop(self.key)
|
|
1519
|
+
return True, coll, Insert(self.key, value, idx)
|
|
1520
|
+
if isinstance(coll, list):
|
|
1521
|
+
if not (isinstance(self.key, int) and 0 <= self.key < len(coll)):
|
|
1522
|
+
return False, coll, None
|
|
1523
|
+
value = coll.pop(self.key)
|
|
1524
|
+
return True, coll, Insert(self.key, value, self.key)
|
|
1525
|
+
return False, coll, None
|