meltygui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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