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,591 @@
1
+ """Tiled window manager: a Blender-style tree of splits drawn flat.
2
+
3
+ The layout is a TREE of ``Split`` nodes (an axis + children) ending in
4
+ ``Tile`` leaves. Plain functions lay out the split tree on one host draw_state;
5
+ each leaf's editor is a normal render_func with independent injected state.
6
+ One ``Split`` is ONE ``ColumnLayout`` (axis "x") or
7
+ ``RowLayout`` (axis "y") call over its children; a child of the OPPOSITE
8
+ axis is the only thing that recurses, and a Split never holds a Split of
9
+ its own axis (``normalize`` keeps that invariant, mirroring Blender's
10
+ areas: same-axis neighbours are siblings in one flat list).
11
+
12
+ Every node renders into a FRAME of four shared edge dicts
13
+ ``(left, right, top, bottom)`` — the enclosing cell's pair on the split's
14
+ axis and the pass-through pair on the other — so a Rows in a Columns cell
15
+ collides with the outer rows exactly like draw_columns / draw_rows do.
16
+ The interior edges of a Split live IN THE TREE (``node.edges``, the
17
+ n-1 dicts between its children, window coords); its far edges are never
18
+ stored, they are adopted by reference from the frame every frame. The
19
+ layouts register on the host window with ``key=path`` and ``band=`` (see
20
+ columns.py), so one draw_state hosts the whole tree and every divider,
21
+ including the window frame, drags through the same collision solve.
22
+
23
+ Data ops (``split_tile`` / ``join_tiles`` / ``normalize``) are pure list
24
+ edits on the tree; ``resolve_frames`` walks the stored edges from a root
25
+ frame without rendering, so they can place a new edge at a midpoint.
26
+
27
+ Gestures (Blender's): a left-drag from a tile's CORNER inward splits it —
28
+ a mostly-horizontal drag cuts a vertical divider (axis "x"), a vertical
29
+ one a horizontal divider (axis "y"); the new tile sits on the corner's
30
+ side and the new edge follows the cursor for the rest of the drag through
31
+ the same collision solve as any divider (``tile_corner_gesture``). The
32
+ live gesture lives in the injected ``TileManagerState``. An outward drag
33
+ previews joining a leaf sibling across its whole edge; release removes
34
+ that neighbour and expands the dragged-from tile. Returning inside the
35
+ source or leaving the neighbour cancels the join.
36
+ """
37
+ import meltygui_imgui as imgui
38
+
39
+ from meltygui.model.tile_model import Tile
40
+ from meltygui.model.tile_model import Split
41
+
42
+ import meltygui.core.input.mouse_cursor as mouse_cursor
43
+ from meltygui.hdr_color import pack_color
44
+ from meltygui.core.conversion.dict_conversion import DictConversion
45
+ from meltygui.core.cache.tile_cache import snap_int
46
+ from meltygui.core.layout.column_core import ColumnLayout
47
+ from meltygui.core.layout.column_core import RowLayout
48
+ from meltygui.core.layout.column_core import MIN_COLUMN_WIDTH
49
+ from meltygui.core.layout.column_core import MIN_ROW_HEIGHT
50
+ from meltygui.core.layout.column_core import _ensure_window_state
51
+ from meltygui.core.layout.column_core import _pending
52
+ from meltygui.core.layout.column_core import _views
53
+ from meltygui.core.layout.column_core import _specs
54
+ from meltygui.core.layout.column_core import _bands
55
+ from meltygui.core.layout.column_core import frame_edges
56
+ from meltygui.core.layout.column_core import layout_window
57
+ from meltygui.core.layout.column_core import _drag_inc
58
+ from meltygui.core.layout.column_core import _grab_zone
59
+ from meltygui.core.rendering.core_decoration import Core
60
+ from meltygui.core.rendering.core_decoration import no_save
61
+ from meltygui.core.cache.invalidation_tracker import Note
62
+
63
+ _NOTE = dict(name="draw_tiles", tint=(0.55, 0.85, 0.45))
64
+
65
+
66
+ @no_save("gesture")
67
+ class TileManagerState(DictConversion):
68
+ """Injected state of a tile-manager host (declare
69
+ ``tile_state: TileManagerState = None`` on the render_func). ``gesture``
70
+ holds the captured corner and either the new split edge or a join
71
+ preview target, until the button is released. It is never persisted."""
72
+
73
+ def __init__(self):
74
+ super().__init__()
75
+ self.gesture = None
76
+ self.content_top = {"y": 0.0}
77
+
78
+
79
+ # The four corners of a tile: (name, on the left?, on the top?).
80
+ CORNERS = (("nw", True, True), ("ne", False, True),
81
+ ("sw", True, False), ("se", False, False))
82
+
83
+
84
+ def other_axis(axis):
85
+ return "y" if axis == "x" else "x"
86
+
87
+
88
+ def frame_pair(frame, axis):
89
+ """The frame's two edges on ``axis``: (left, right) or (top, bottom)."""
90
+ left, right, top, bottom = frame
91
+ return (left, right) if axis == "x" else (top, bottom)
92
+
93
+
94
+ def child_frame(frame, axis, near, far):
95
+ """The frame a child gets: the parent's frame with the ``axis`` pair
96
+ replaced by the child's cell edges, the other pair passed through."""
97
+ left, right, top, bottom = frame
98
+ if axis == "x":
99
+ return near, far, top, bottom
100
+ return left, right, near, far
101
+
102
+
103
+ def full_edges(node, frame):
104
+ """A Split's complete edge list — far edges from the frame, interior
105
+ edges from the tree — the list its layout is built over."""
106
+ near, far = frame_pair(frame, node.axis)
107
+ return [near, *node.edges, far]
108
+
109
+
110
+ def node_at(tree, path):
111
+ """The node at ``path`` (a tuple of child indices from the root)."""
112
+ node = tree
113
+ for index in path:
114
+ node = node.children[index]
115
+ return node
116
+
117
+
118
+ def walk(tree, path=()):
119
+ """Every ``(path, node)`` of the tree, parents before children, seeded
120
+ or not."""
121
+ yield path, tree
122
+ if isinstance(tree, Split):
123
+ for index, child in enumerate(tree.children):
124
+ yield from walk(child, path + (index,))
125
+
126
+
127
+ def resolve_frames(tree, root_frame, path=()):
128
+ """Walk the tree WITHOUT rendering, yielding ``(path, node, frame)``
129
+ for every node from the stored edges alone. A Split whose interior
130
+ edges have not been seeded yet (fewer than n-1) is yielded, but its
131
+ children are skipped — their frames don't exist until a render seeds
132
+ them."""
133
+ yield path, tree, root_frame
134
+ if not isinstance(tree, Split):
135
+ return
136
+ children = tree.children
137
+ if len(tree.edges) != max(len(children) - 1, 0):
138
+ return
139
+ edges = full_edges(tree, root_frame)
140
+ for index, child in enumerate(children):
141
+ frame = child_frame(root_frame, tree.axis, edges[index], edges[index + 1])
142
+ yield from resolve_frames(child, frame, path + (index,))
143
+
144
+
145
+ def frame_rect(frame, window):
146
+ """A frame as an absolute screen rect ``(x0, y0, x1, y1)``."""
147
+ left, right, top, bottom = frame
148
+ return (window.abs_left + left["x"], window.abs_top + top["y"],
149
+ window.abs_left + right["x"], window.abs_top + bottom["y"])
150
+
151
+
152
+ def tile_rect(frame, draw_state, gap=4.0):
153
+ """A tile's painted box (absolute, ints): its frame inset by ``gap``
154
+ / 2 and clipped to the host's visible box, or None when nothing of it
155
+ shows."""
156
+ window = layout_window(draw_state)
157
+ x0, y0, x1, y1 = frame_rect(frame, window)
158
+ clip = Core.melty.get_clip_rect() or draw_state.abs_clip_rect
159
+ if getattr(draw_state, "closable", False):
160
+ content = draw_state.get_content_rect()
161
+ clip = content if clip is None else (
162
+ max(clip[0], content[0]), max(clip[1], content[1]),
163
+ min(clip[2], content[2]), min(clip[3], content[3]))
164
+ half = gap / 2
165
+ x0, y0 = snap_int(x0 + half), snap_int(y0 + half)
166
+ x1, y1 = snap_int(x1 - half), snap_int(y1 - half)
167
+ if clip is not None:
168
+ x0, y0 = max(x0, clip[0]), max(y0, clip[1])
169
+ x1, y1 = min(x1, clip[2]), min(y1, clip[3])
170
+ if x1 <= x0 or y1 <= y0:
171
+ return None
172
+ return x0, y0, x1, y1
173
+
174
+
175
+ def corner_rect(rect, on_left, on_top, size):
176
+ """The ``size`` px square in one corner of ``rect``."""
177
+ x0, y0, x1, y1 = rect
178
+ cx0 = x0 if on_left else x1 - size
179
+ cy0 = y0 if on_top else y1 - size
180
+ return cx0, cy0, cx0 + size, cy0 + size
181
+
182
+
183
+ def draw_tile(tile, frame, draw_state, path=(), tree=None, root_frame=None,
184
+ tile_state=None, gap=4.0):
185
+ """Paint one leaf — no background (``draw_split_dividers`` draws the
186
+ lines between tiles), only a corner grip where hovered — and run the
187
+ corner split gesture when ``tree`` /
188
+ ``root_frame`` / ``tile_state`` are given (``draw_tiles`` passes
189
+ them). Returns True when the gesture changed the tree."""
190
+ # [tint=(1.0, 0.8, 0.3)]
191
+ corner_size = 14.0
192
+ corner_hover_color = (1.0, 1.0, 1.0, 0.35)
193
+
194
+ rect = tile_rect(frame, draw_state, gap=gap)
195
+ if rect is None:
196
+ return False
197
+ draw_list = imgui.get_window_draw_list()
198
+
199
+ changed = False
200
+ for name, on_left, on_top in CORNERS:
201
+ grip = corner_rect(rect, on_left, on_top, corner_size)
202
+ if draw_state.hover_eligible(rect=grip):
203
+ draw_list.add_rect_filled(*grip, pack_color(*corner_hover_color))
204
+ if tree is not None and root_frame is not None and tile_state is not None:
205
+ changed = tile_corner_gesture(
206
+ tile, frame, draw_state, path, tree, root_frame, tile_state,
207
+ name, on_left, on_top, grip) or changed
208
+ return changed
209
+
210
+
211
+ def tile_corner_gesture(tile, frame, draw_state, path, tree, root_frame,
212
+ tile_state, corner, on_left, on_top, grip):
213
+ """One corner's split / join gesture. The grip is an ``on_action`` sub-rect
214
+ above the edge grab zones (the two overlap at a tile's corners).
215
+ A drag that travels ``split_threshold`` px INTO the tile splits it:
216
+ the dominant direction picks the axis, the new tile goes on the
217
+ corner's side, the new edge lands under the cursor. From then until
218
+ release every frame queues a cursor-driven drag of that edge on the
219
+ window's solve, so it follows the pointer and pushes neighbours like
220
+ any divider. The view id keys on the tile OBJECT, not its path — the
221
+ split shifts the path while the drag is still captured. Returns True
222
+ on the frame the tree changed."""
223
+ # [tint=(1.0, 0.8, 0.3)]
224
+ split_threshold = 8.0
225
+
226
+ view_id = f"tile_corner_{id(tile)}_{corner}"
227
+ draw_state.on_action("left_mouse_down", view_id=view_id, rect=grip,
228
+ priority_delta=2, cursor=mouse_cursor.RESIZE_ALL)
229
+ drag = draw_state.on_action("left_mouse_drag", view_id=view_id, rect=grip,
230
+ priority_delta=2)
231
+ gesture = tile_state.gesture
232
+ window = layout_window(draw_state)
233
+
234
+ if gesture is not None and gesture["corner"] == view_id:
235
+ if gesture.get("kind") == "join":
236
+ if drag is None:
237
+ tile_state.gesture = None
238
+ target = gesture["target"]
239
+ target_path = locate(tree, target) if target is not None else None
240
+ source_path = locate(tree, tile)
241
+ if target_path is not None and source_path is not None:
242
+ direction = -1 if target_path[-1] > source_path[-1] else +1
243
+ join_tiles(tree, target_path, direction)
244
+ return True
245
+ return False
246
+ tile_state.gesture = {**gesture, "target": join_target(tree, path, root_frame, window, drag)}
247
+ return False
248
+ if drag is None:
249
+ tile_state.gesture = None
250
+ draw_state._drag_totals.pop(view_id, None)
251
+ return False
252
+ # Use the same relative increments as every divider. An absolute cursor
253
+ # target re-applied at a wall accumulates the blocked distance on every
254
+ # frame of sticky replay, even when the hand is stationary.
255
+ axis = gesture["axis"]
256
+ increment = _drag_inc(draw_state, view_id, drag,
257
+ total="total_dx" if axis == "x" else "total_dy")
258
+ if increment:
259
+ _ensure_window_state(window)
260
+ edge = gesture["edge"]
261
+ _pending(window, axis).append((edge, edge[axis] + increment, True))
262
+ draw_state.invalidate(note=Note(reason="tile split drag", **_NOTE))
263
+ return False
264
+
265
+ if drag is None or gesture is not None:
266
+ return False
267
+ dx, dy = drag.total_dx, drag.total_dy
268
+ if max(abs(dx), abs(dy)) < split_threshold:
269
+ return False
270
+ axis = "x" if abs(dx) >= abs(dy) else "y"
271
+ inward = (dx > 0) == on_left if axis == "x" else (dy > 0) == on_top
272
+ if not inward:
273
+ target = join_target(tree, path, root_frame, window, drag)
274
+ if target is not None:
275
+ tile_state.gesture = {"kind": "join", "corner": view_id, "target": target}
276
+ return False
277
+ before = on_left if axis == "x" else on_top
278
+ near, far = frame_pair(frame, axis)
279
+ floor = MIN_COLUMN_WIDTH if axis == "x" else MIN_ROW_HEIGHT
280
+ along = (drag.x - window.abs_left) if axis == "x" else (drag.y - window.abs_top)
281
+ lo, hi = near[axis] + floor, far[axis] - floor
282
+ if hi < lo:
283
+ return False # too small to split
284
+ at = min(max(float(along), lo), hi)
285
+ new_path = split_tile(tree, path, axis, root_frame, at=at, before=before)
286
+ new_node = node_at(tree, new_path[:-1])
287
+ edge_index = new_path[-1] if before else new_path[-1] - 1
288
+ tile_state.gesture = {"corner": view_id, "axis": axis,
289
+ "edge": new_node.edges[edge_index],
290
+ "new_tile": node_at(tree, new_path)}
291
+ _drag_inc(draw_state, view_id, drag,
292
+ total="total_dx" if axis == "x" else "total_dy")
293
+ return True
294
+
295
+
296
+ def join_target(tree, path, root_frame, window, drag):
297
+ """Leaf sibling under the pointer across a whole shared edge.
298
+
299
+ Join a subdivided neighbour's own tiles first. Moving back into the
300
+ source or leaving the neighbour cancels the preview.
301
+ """
302
+ if not path:
303
+ return None
304
+ parent = node_at(tree, path[:-1])
305
+ frames = {path: frame for path, _node, frame in resolve_frames(tree, root_frame)}
306
+ for direction in (-1, +1):
307
+ neighbour = path[-1] + direction
308
+ if not 0 <= neighbour < len(parent.children):
309
+ continue
310
+ candidate = parent.children[neighbour]
311
+ if isinstance(candidate, Split):
312
+ continue
313
+ frame = frames.get(path[:-1] + (neighbour,))
314
+ if frame is None:
315
+ continue
316
+ left, top, right, bottom = frame_rect(frame, window)
317
+ if left < drag.x < right and top < drag.y < bottom:
318
+ return candidate
319
+ return None
320
+
321
+
322
+ def draw_join_preview(tree, root_frame, draw_state, tile_state):
323
+ gesture = tile_state.gesture
324
+ if gesture is None or gesture.get("kind") != "join" or gesture["target"] is None:
325
+ return
326
+ # [tint=(1.0, 0.55, 0.15)]
327
+ preview_color = (1.0, 0.55, 0.15, 0.42)
328
+ for _path, tile, frame in resolve_frames(tree, root_frame):
329
+ if tile is gesture["target"]:
330
+ rect = tile_rect(frame, draw_state)
331
+ if rect is not None:
332
+ draw_list = imgui.get_window_draw_list()
333
+ draw_list.add_rect_filled(*rect, pack_color(*preview_color))
334
+ draw_list.add_text(rect[0] + 8, rect[1] + 28,
335
+ pack_color(1.0, 1.0, 1.0, 1.0), "Release to merge")
336
+ break
337
+
338
+
339
+ def draw_split_dividers(layout, axis, frame, draw_state):
340
+ """Draw one Split's interior edges as lines across its band: black at
341
+ rest, highlighted while the cursor is in that edge's grab zone (the
342
+ same zone the layout drags from) or the edge is being dragged."""
343
+ # [tint=(1.0, 0.8, 0.3)]
344
+ # Change the divider look here: resting / hovered colour and line width.
345
+ divider_color = (0.0, 0.0, 0.0, 1.0)
346
+ divider_hover_color = (0.35, 0.65, 1.0, 1.0)
347
+ divider_thickness = 2.0
348
+
349
+ window = layout_window(draw_state)
350
+ x0, y0, x1, y1 = frame_rect(frame, window)
351
+ origin = window.abs_left if axis == "x" else window.abs_top
352
+ edges = layout.edges
353
+ draw_list = imgui.get_window_draw_list()
354
+ for k in range(1, len(edges) - 1):
355
+ lo, hi = _grab_zone(edges, k, axis=axis)
356
+ line = snap_int(origin + edges[k][axis])
357
+ if axis == "x":
358
+ grab = (origin + lo, y0, origin + hi, y1)
359
+ ends = (line, y0, line, y1)
360
+ else:
361
+ grab = (x0, origin + lo, x1, origin + hi)
362
+ ends = (x0, line, x1, line)
363
+ hot = layout.active_edge == k or draw_state.hover_eligible(rect=grab)
364
+ draw_list.add_line(*ends,
365
+ pack_color(*(divider_hover_color if hot else divider_color)),
366
+ divider_thickness)
367
+
368
+
369
+ def draw_tile_node(node, frame, draw_state, path=(), tree=None,
370
+ root_frame=None, tile_state=None, gap=4.0):
371
+ """Render one node into ``frame``. A Tile paints (and runs its corner
372
+ gestures when ``tree`` / ``root_frame`` / ``tile_state`` are given); a
373
+ Split builds ONE layout over its children (columns for axis "x", rows
374
+ for "y") keyed by ``path`` so many layouts share the host draw_state,
375
+ writes the seeded / clamped interior edges back into the tree, and
376
+ recurses into each child with that child's cell edges as its frame.
377
+ Returns True when a gesture changed the tree — the children are
378
+ snapshotted first, so a split mid-walk renders on the next frame."""
379
+ if not isinstance(node, Split):
380
+ return draw_tile(node, frame, draw_state, path=path, tree=tree,
381
+ root_frame=root_frame, tile_state=tile_state, gap=gap)
382
+ children = node.children
383
+ if not children:
384
+ return False
385
+ axis = node.axis
386
+ near, far = frame_pair(frame, axis)
387
+ across = frame_pair(frame, other_axis(axis))
388
+ stored = full_edges(node, frame)
389
+ if axis == "x":
390
+ layout = ColumnLayout(draw_state, len(children), column_edges=stored,
391
+ left_edge=near, right_edge=far, band=across,
392
+ key=path, persist=False, padding=0.0,
393
+ border_color=None)
394
+ else:
395
+ layout = RowLayout(draw_state, len(children), row_edges=stored,
396
+ top_edge=near, bottom_edge=far, band=across,
397
+ key=path, persist=False, padding=0.0,
398
+ border_color=None)
399
+ if layout.seed_valid:
400
+ interior = layout.edges[1:-1]
401
+ if any(a is not b for a, b in zip(node.edges, interior)) \
402
+ or len(node.edges) != len(interior):
403
+ node.edges = interior
404
+ edges = layout.edges
405
+ draw_split_dividers(layout, axis, frame, draw_state)
406
+ changed = False
407
+ for index, child in enumerate(list(children)):
408
+ if draw_tile_node(child, child_frame(frame, axis, edges[index], edges[index + 1]),
409
+ draw_state, path + (index,), tree=tree,
410
+ root_frame=root_frame, tile_state=tile_state, gap=gap):
411
+ changed = True
412
+ break # the tree moved under us
413
+ return changed
414
+
415
+
416
+ def draw_tiles(tree, draw_state, tile_state=None, gap=4.0,
417
+ multi_instance_renderers=(), content_top=None):
418
+ """Render a whole tile tree over the host window's frame: the root
419
+ adopts the window's four frame edges (``frame_edges``), so the window
420
+ frame and every divider move through one collision solve. Call from a
421
+ render_func body with its injected ``tile_state`` (without one the
422
+ tiles draw and their edges drag, but corners don't split). Returns
423
+ True when the layout, editor selection or editor content changed.
424
+ ``multi_instance_renderers`` is supplied by the hosting render_func's
425
+ injected parameter of the same name. ``content_top`` optionally reserves
426
+ space above the tiles (an absolute screen y coordinate). The root must be a Split."""
427
+ window = layout_window(draw_state)
428
+ root_frame = frame_edges(window)
429
+ if content_top is not None:
430
+ # Keep the body boundary stable while menus above it change height.
431
+ top = tile_state.content_top if tile_state is not None else {}
432
+ top["y"] = content_top - window.abs_top
433
+ root_frame = (*root_frame[:2], top, root_frame[3])
434
+ changed = draw_tile_node(tree, root_frame, draw_state, (), tree=tree,
435
+ root_frame=root_frame, tile_state=tile_state,
436
+ gap=gap)
437
+ if changed:
438
+ # Retire layouts whose paths/axes moved before the next collision solve.
439
+ for axis in ("x", "y"):
440
+ for key, entry in list(_views(window, axis).items()):
441
+ if isinstance(key, tuple) and len(key) == 3 and entry[0] is draw_state:
442
+ del _views(window, axis)[key]
443
+ _specs(window, axis).pop(key, None)
444
+ _bands(window, axis).pop(key, None)
445
+ # Render content after topology edits, using the final leaf frames. The
446
+ # conversion's existing identity scopes views independently of tree paths.
447
+ from meltygui.view.tile_view import draw_tile_content
448
+ cursor = imgui.get_cursor_screen_pos()
449
+ for _path, tile, frame in resolve_frames(tree, root_frame):
450
+ if isinstance(tile, Split):
451
+ continue
452
+ if (tile_state is not None and tile_state.gesture is not None
453
+ and tile_state.gesture.get("new_tile") is tile):
454
+ # Instantiate the new editor at the committed split size, not the
455
+ # tiny first drag frame; otherwise its own column widths persist
456
+ # that transient geometry while the corner is still moving.
457
+ continue
458
+ rect = tile_rect(frame, draw_state, gap)
459
+ if rect is None:
460
+ continue
461
+ left, top, right, bottom = rect
462
+ # Leave the corner grips free to receive split/join gestures.
463
+ grip_inset = 14.0
464
+ if right - left <= 2 * grip_inset or bottom - top <= 2 * grip_inset:
465
+ continue
466
+ imgui.set_cursor_screen_pos((left + grip_inset, top + grip_inset))
467
+ content_changed, _ = draw_tile_content(
468
+ tile, width=right - left - 2 * grip_inset,
469
+ height=bottom - top - 2 * grip_inset,
470
+ multi_instance_renderers=multi_instance_renderers,
471
+ layout_frame=frame,
472
+ )
473
+ changed |= content_changed
474
+ imgui.set_cursor_screen_pos(cursor)
475
+ if tile_state is not None:
476
+ draw_join_preview(tree, root_frame, draw_state, tile_state)
477
+ left, right, top, bottom = root_frame
478
+ imgui.dummy(max(0.0, right["x"] - left["x"]), max(0.0, bottom["y"] - top["y"]))
479
+ return changed
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # Data interface - pure edits on the tree
484
+ # ---------------------------------------------------------------------------
485
+
486
+ def midpoint(near, far, axis):
487
+ return {axis: (near[axis] + far[axis]) / 2}
488
+
489
+
490
+ def split_tile(tree, path, axis, root_frame, new_tile=None, at=None,
491
+ before=False):
492
+ """Split the tile at ``path`` along ``axis`` — Blender's area split.
493
+ The new tile lands after the old one (right of / below it), or before
494
+ it with ``before=True``, with a new edge at ``at`` (window coords on
495
+ that axis) or at the midpoint of the tile's frame (frames come from
496
+ ``resolve_frames`` over ``root_frame``). Same axis as the parent: a
497
+ new sibling in the parent's flat list. Otherwise the leaf becomes a
498
+ two-child Split of ``axis``. Returns the new tile's path."""
499
+ if not path:
500
+ raise ValueError("tile layouts require a Split root containing the leaf")
501
+ frames = {p: f for p, _n, f in resolve_frames(tree, root_frame)}
502
+ frame = frames[path]
503
+ near, far = frame_pair(frame, axis)
504
+ edge = midpoint(near, far, axis) if at is None else {axis: float(at)}
505
+ tile = node_at(tree, path)
506
+ if new_tile is None:
507
+ new_tile = Tile(name=f"{tile.name}'", tint=tile.tint,
508
+ render_func=tile.render_func, input_value=tile.input_value)
509
+ pair = [new_tile, tile] if before else [tile, new_tile]
510
+ new_offset = 0 if before else 1
511
+ parent = node_at(tree, path[:-1]) if path else None
512
+ if parent is not None and parent.axis == axis:
513
+ index = path[-1]
514
+ parent.children[index:index + 1] = pair
515
+ parent.edges.insert(index, edge)
516
+ return path[:-1] + (index + new_offset,)
517
+ split = Split(axis=axis, children=pair, edges=[edge])
518
+ parent.children[path[-1]] = split
519
+ return path + (new_offset,)
520
+
521
+
522
+ def join_tiles(tree, path, direction=+1):
523
+ """Join the tile at ``path`` into its sibling ``direction`` away (+1 =
524
+ the next, -1 = the previous) — Blender's area join: the tile and the
525
+ edge between them go, the neighbour takes the room. Returns the
526
+ neighbour's path after normalization."""
527
+ if direction not in (-1, +1):
528
+ raise ValueError("join direction must be -1 or +1")
529
+ if not path:
530
+ raise ValueError("cannot join the root")
531
+ parent = node_at(tree, path[:-1])
532
+ index = path[-1]
533
+ neighbour = index + direction
534
+ if not 0 <= neighbour < len(parent.children):
535
+ raise ValueError("no neighbour to join into")
536
+ if isinstance(parent.children[index], Split) or isinstance(parent.children[neighbour], Split):
537
+ raise ValueError("join requires two leaf tiles sharing a whole edge")
538
+ seeded = len(parent.edges) == len(parent.children) - 1
539
+ parent.children.pop(index)
540
+ if seeded:
541
+ parent.edges.pop(min(index, neighbour))
542
+ kept = neighbour if neighbour < index else index
543
+ kept_node = parent.children[kept]
544
+ normalize(tree)
545
+ return locate(tree, kept_node)
546
+
547
+
548
+ def locate(tree, target):
549
+ """The path of ``target`` (by identity) in the tree, or None."""
550
+ for path, node in walk(tree):
551
+ if node is target:
552
+ return path
553
+ return None
554
+
555
+
556
+ def normalize(tree):
557
+ """Restore the invariants after an edit: a Split with ONE child is
558
+ replaced by that child (the root keeps its Split shell, so the caller's
559
+ object identity holds), and a Split child of its parent's axis is
560
+ spliced into the parent's list, edges included. Returns the tree."""
561
+ changed = True
562
+ while changed:
563
+ changed = False
564
+ for _path, node in list(walk(tree)):
565
+ if not isinstance(node, Split):
566
+ continue
567
+ children = node.children
568
+ for index, child in enumerate(list(children)):
569
+ if not isinstance(child, Split):
570
+ continue
571
+ if len(child.children) == 1:
572
+ children[index] = child.children[0]
573
+ changed = True
574
+ elif child.axis == node.axis:
575
+ children[index:index + 1] = child.children
576
+ # The child's interior edges join the node's list at
577
+ # the slot before this cell's far edge.
578
+ node.edges[index:index] = child.edges
579
+ changed = True
580
+ if changed:
581
+ break
582
+ if changed:
583
+ break
584
+ # The root itself: one Split child of the root collapses into it.
585
+ if isinstance(tree, Split) and len(tree.children) == 1 \
586
+ and isinstance(tree.children[0], Split):
587
+ only = tree.children[0]
588
+ tree.axis = only.axis
589
+ tree.children = only.children
590
+ tree.edges = only.edges
591
+ return tree