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,2153 @@
1
+ from contextlib import contextmanager
2
+
3
+ from meltygui.core.layout import edge_constraints
4
+
5
+ import meltygui_imgui as imgui
6
+ from meltygui.hdr_color import pack_color
7
+
8
+ import meltygui.core.input.mouse_cursor as mouse_cursor
9
+ from meltygui.core.windowing.glfw_utils import request_render
10
+ from meltygui.core.cache.tile_cache import snap_int
11
+ from meltygui.core.core_render import render_func
12
+ from meltygui.core.rendering.core_decoration import Core
13
+ from meltygui.core.rendering.render_dispatch import draw_any
14
+ from meltygui.core.cache.invalidation_tracker import Note
15
+
16
+ MIN_COLUMN_WIDTH = 60
17
+ # Minimum span of a ROW cell (the row-axis twin of MIN_COLUMN_WIDTH), and
18
+ # the floor every row band / grabber reads when a host hasn't measured
19
+ # its height yet.
20
+ MIN_ROW_HEIGHT = 40
21
+ EDGE_GRAB_WIDTH = 20.0
22
+ # Band color when the cursor is over the row (hard-coded for now).
23
+ HIGHLIGHT_TINT = (0,0,0, 1.0)
24
+
25
+ _NOTE = dict(name="draw_columns", tint=(0.5, 0.8, 1.0))
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Edge model
30
+ #
31
+ # An EDGE is a dict with a single float on its AXIS - {"x": 123.0} for a
32
+ # column edge, {"y": 123.0} for a row edge - in WINDOW coordinates (offset
33
+ # from window.abs_left / window.abs_top). Edges are passed around BY
34
+ # REFERENCE: a nested columns view does not create its own far edges, it
35
+ # adopts the two edge objects of its enclosing cell, so a shared boundary is
36
+ # always the same object in both views and can never drift apart.
37
+ #
38
+ # ALL edges live on the ROOT WINDOW's draw_state, one registry per axis:
39
+ # every columns view in that window upserts its edge list into
40
+ # window._edge_views (rows views into window._row_views), and drags from any
41
+ # view queue on window._pending_drags (window._pending_row_drags). Once per
42
+ # frame - triggered by the first layout view that renders - the window
43
+ # resolves each queue in one collision solve over the whole cells of that
44
+ # axis: Each registered edge list contributes its cells (consecutive
45
+ # pairs of its own edges, with each cell's floor / cap), so two edges only
46
+ # ever push or pull each other from the cell that spans between them. A
47
+ # nested layout shares its far edges with the enclosing cell by reference,
48
+ # so contact crosses nesting levels by needed; layouts that share a cell -
49
+ # the columns of two different rows, the rows of two different columns -
50
+ # are independent however close their lines come (the the sort-by-
51
+ # position version this replaced collided them, 08-26). Net motion of the
52
+ # window-direct row's far edges drives the window size itself (with a
53
+ # recenter so interior lines hold their screen positions); contributors are
54
+ # invalidated and every view lines its cells up with the same edges in
55
+ # that same frame.
56
+ #
57
+ # The two axes never interact: an x-edge only ever collides with x-edges.
58
+ # Every function below that walks edges takes ``axis`` ("x" default, "y"
59
+ # for rows) and reads the coordinate under that key.
60
+ # ---------------------------------------------------------------------------
61
+
62
+ # Per-axis registry attribute names on the window draw_state:
63
+ # (edge views, pending drags, frame edge pair, cell specs). Cell specs are
64
+ # keyed like the views: key → (floors, caps), one entry per cell in the
65
+ # view's list - a cell's floor / cap belong to the VIEW that owns the
66
+ # cell, never to the cell's the edge dict: two cells ending on the same
67
+ # edge (an outer column and the nested view's last column, the window
68
+ # frame and the last column) have their own.
69
+ # The fifth slot holds the across-axis BAND a keyed layout registered
70
+ # with its edge list (``band=`` on ColumnLayout / RowLayout): a pair of
71
+ # edge dicts of the OTHER axis, read live, so _edge_under_cursor can scope
72
+ # a layout that shares its draw_state's clip with many siblings (a tile
73
+ # tree built flat layout a draw_state).
74
+ _REGISTRY = {"x": ("_edge_views", "_pending_drags", "_frame_edges", "_edge_cells",
75
+ "_edge_bands"),
76
+ "y": ("_row_views", "_pending_row_drags", "_frame_rows", "_row_cells",
77
+ "_row_bands")}
78
+
79
+
80
+ def _axis_min(axis):
81
+ """The flat minimum span between consecutive edges on ``axis``: a
82
+ column's MIN_COLUMN_WIDTH along x, a row's MIN_ROW_HEIGHT along y."""
83
+ return float(MIN_COLUMN_WIDTH if axis == "x" else MIN_ROW_HEIGHT)
84
+
85
+
86
+ def _views(window, axis):
87
+ return getattr(window, _REGISTRY[axis][0])
88
+
89
+
90
+ def _pending(window, axis):
91
+ return getattr(window, _REGISTRY[axis][1])
92
+
93
+
94
+ def _frame(window, axis):
95
+ return getattr(window, _REGISTRY[axis][2], None)
96
+
97
+
98
+ def _specs(window, axis):
99
+ return getattr(window, _REGISTRY[axis][3])
100
+
101
+
102
+ def layout_window(draw_state):
103
+ """The coordinate owner of a layout, including a window's own body.
104
+
105
+ parent_window is the containing window of an ordinary view, but the
106
+ PARENT of a closable window. A window body owns its own frame/cells;
107
+ adopting its parent's edges separates its contents from its movement.
108
+ """
109
+ if getattr(draw_state, "closable", False):
110
+ return draw_state
111
+ return draw_state.parent_window or draw_state
112
+
113
+
114
+ def frame_edges(window):
115
+ """The window's four frame edge dicts ``(left, right, top, bottom)`` —
116
+ the root frame a layout drawn straight on the window adopts (the tile
117
+ manager's root). Runs the per-frame edge pass so the pairs exist."""
118
+ _ensure_window_state(window)
119
+ window_edge_pass(window)
120
+ left, right = _frame(window, "x") or ({"x": 0.0}, {"x": float(window.width or 0)})
121
+ top, bottom = _frame(window, "y") or ({"y": 0.0}, {"y": float(window.height or 0)})
122
+ return left, right, top, bottom
123
+
124
+
125
+ def _bands(window, axis):
126
+ return getattr(window, _REGISTRY[axis][4])
127
+
128
+
129
+ def layout_key(kind, draw_state, key=None):
130
+ """The registry key of one layout on its window: ``("row", ds.id)`` for
131
+ a ColumnLayout / ``("rows", ds.id)`` for a RowLayout — NOT the bare
132
+ draw_state id (a host ds that IS the window would clobber the frame
133
+ entry) — with ``key`` folded in when the host builds SEVERAL layouts of
134
+ one axis on the same draw_state (each needs its own entry, handles and
135
+ drag baselines; see ``key=`` on the layouts)."""
136
+ return (kind, draw_state.id) if key is None else (kind, draw_state.id, key)
137
+
138
+
139
+ def _column_floor(column_mins, i):
140
+ """Minimum width of column ``i``: its ``column_mins`` entry when given
141
+ (None / 0 / a short list fall through), else MIN_COLUMN_WIDTH."""
142
+ if column_mins and i < len(column_mins) and column_mins[i]:
143
+ return float(column_mins[i])
144
+ return float(MIN_COLUMN_WIDTH)
145
+
146
+
147
+ def _row_floor(row_mins, i):
148
+ """Minimum height of row ``i`` — _column_floor's twin over
149
+ MIN_ROW_HEIGHT."""
150
+ if row_mins and i < len(row_mins) and row_mins[i]:
151
+ return float(row_mins[i])
152
+ return float(MIN_ROW_HEIGHT)
153
+
154
+
155
+ def _column_cap(column_maxes, i, floor):
156
+ """Maximum width of column ``i``: its ``column_maxes`` entry when given
157
+ (never below the column's ``floor`` — a cap under the minimum reads as
158
+ the minimum), else None: unbounded."""
159
+ if column_maxes and i < len(column_maxes) and column_maxes[i]:
160
+ return max(float(column_maxes[i]), float(floor))
161
+ return None
162
+
163
+
164
+ def resolve_column_widths(column_widths, n_cols, content_width,
165
+ column_mins=None, column_maxes=None, axis="x"):
166
+ """Pixel width per column for ``n_cols`` columns in ``content_width``.
167
+
168
+ Entries are pixels; None (or a missing entry — the list may be shorter
169
+ than the column count) takes an equal share of whatever the sized columns
170
+ leave over. Widths never drop below the column's minimum
171
+ (``column_mins`` per column, else MIN_COLUMN_WIDTH) and never pass its
172
+ cap (``column_maxes`` per column, None = unbounded): a flex column
173
+ whose equal share would overshoot its cap takes the cap, and the
174
+ remaining flex columns split what it left on the table.
175
+
176
+ The same arithmetic sizes ROWS: ``axis="y"`` reads the row floors
177
+ (MIN_ROW_HEIGHT) for entries ``column_mins`` doesn't cover.
178
+ """
179
+ available = max(0.0, float(content_width))
180
+ spec = list(column_widths)[:n_cols] if column_widths else []
181
+ spec += [None] * (n_cols - len(spec))
182
+ floor_of = _column_floor if axis == "x" else _row_floor
183
+ mins = [floor_of(column_mins, i) for i in range(n_cols)]
184
+ maxes = [_column_cap(column_maxes, i, mins[i]) for i in range(n_cols)]
185
+
186
+ def bounded(i, w):
187
+ w = max(float(w), mins[i])
188
+ return w if maxes[i] is None else min(w, maxes[i])
189
+
190
+ widths = [None if w is None else bounded(i, w) for i, w in enumerate(spec)]
191
+ remaining = available - sum(w for w in widths if w is not None)
192
+ pool = [i for i, w in enumerate(widths) if w is None]
193
+ while pool:
194
+ # Equal share is floored at the largest min in the pool; any pool
195
+ # column capped under that share takes its cap and drops out, the
196
+ # rest re-split the remainder.
197
+ share = max(max(mins[i] for i in pool), remaining / len(pool))
198
+ capped = [i for i in pool if maxes[i] is not None and maxes[i] < share]
199
+ if not capped:
200
+ for i in pool:
201
+ widths[i] = share
202
+ break
203
+ for i in capped:
204
+ widths[i] = maxes[i]
205
+ remaining -= maxes[i]
206
+ pool = [i for i in pool if i not in capped]
207
+ return widths
208
+
209
+
210
+ def _seed_edges(column_widths, n_cols, content_width, base=0.0,
211
+ column_mins=None, column_maxes=None, axis="x"):
212
+ """Fresh edge dicts for n_cols columns (rows with ``axis="y"``):
213
+ n_cols+1 lines accumulated from ``base`` (window coordinates)."""
214
+ widths = resolve_column_widths(column_widths, n_cols, content_width,
215
+ column_mins=column_mins,
216
+ column_maxes=column_maxes, axis=axis)
217
+ edges = [{axis: float(base)}]
218
+ for w in widths:
219
+ edges.append({axis: edges[-1][axis] + w})
220
+ return edges
221
+
222
+
223
+ def _edge_min(edges, m, axis="x"):
224
+ """Minimum span between edges[m-1] and edges[m]: the "min" a layout
225
+ stamped on the FAR edge of that column / row (column_mins / row_mins),
226
+ else the axis's flat minimum. The flat solve sorts every edge by
227
+ position and consecutive edges always bound exactly one cell of some
228
+ view, so a per-edge floor slots straight into the contact physics."""
229
+ return float(edges[m].get("min") or _axis_min(axis))
230
+
231
+
232
+ def _edge_max(edges, m):
233
+ """Maximum span between edges[m-1] and edges[m]: the "max" a
234
+ ColumnLayout stamped on the RIGHT edge of that column (column_maxes),
235
+ else None — unbounded. _edge_min's counterpart for the PULL side of a
236
+ drag: a column at its cap can't open any further, so its far edge is
237
+ carried along instead (see _drag_edge)."""
238
+ cap = edges[m].get("max")
239
+ return float(cap) if cap else None
240
+
241
+
242
+ def _clamp_interior(edges, axis="x"):
243
+ """Pack out-of-frame interior edges back inside the far edges at the
244
+ axis's minimum spacing. Edges only ever move on drag CONTACT, so an
245
+ interior edge that lands OUTSIDE its frame — edges persisted from a
246
+ wider window, a width change that ran while the row wasn't registered
247
+ — would otherwise stay there for good: it sorts past the frame edge in
248
+ the flat solve (never pushed) and its grab handle sits off-window where
249
+ no drag can reach it. In-frame, in-order edges are untouched.
250
+
251
+ Then the caps: a column persisted WIDER than its maximum (a cap added
252
+ since, a frame widened while the row wasn't registered) pulls its right
253
+ edge in, the overflow rolling rightward through capped neighbours until
254
+ an uncapped column absorbs it; the LAST column can't move the frame, so
255
+ it pulls its LEFT edge out through the same chain a drag runs, walled
256
+ at both frame edges — a frame wider than every cap put together leaves
257
+ the last column over its cap (the frame is the authority; it never
258
+ fights the width) and the pass is idempotent from then on."""
259
+ count = len(edges)
260
+ for m in range(count - 2, 0, -1):
261
+ limit = edges[m + 1][axis] - _edge_min(edges, m + 1, axis)
262
+ if edges[m][axis] > limit:
263
+ edges[m][axis] = float(limit)
264
+ for m in range(1, count - 1):
265
+ floor = edges[m - 1][axis] + _edge_min(edges, m, axis)
266
+ if edges[m][axis] < floor:
267
+ edges[m][axis] = float(floor)
268
+ for m in range(1, count - 1):
269
+ cap = _edge_max(edges, m)
270
+ if cap is not None and edges[m][axis] - edges[m - 1][axis] > cap:
271
+ edges[m][axis] = edges[m - 1][axis] + cap
272
+ cap = _edge_max(edges, count - 1) if count > 2 else None
273
+ if cap is not None and edges[-1][axis] - edges[-2][axis] > cap:
274
+ _drag_edge(edges, count - 2, edges[-1][axis] - cap,
275
+ walls=frozenset({id(edges[0]), id(edges[-1])}), axis=axis)
276
+
277
+
278
+ def _ensure_window_state(window):
279
+ for views_attr, pending_attr, _frame_attr, specs_attr, bands_attr in _REGISTRY.values():
280
+ if getattr(window, views_attr, None) is None:
281
+ setattr(window, views_attr, {})
282
+ if getattr(window, pending_attr, None) is None:
283
+ setattr(window, pending_attr, [])
284
+ if getattr(window, specs_attr, None) is None:
285
+ setattr(window, specs_attr, {})
286
+ if getattr(window, bands_attr, None) is None:
287
+ setattr(window, bands_attr, {})
288
+
289
+
290
+ def _all_edges(window, axis="x"):
291
+ """Every edge of ``axis`` registered on the window, deduped by identity
292
+ (shared refs appear once)."""
293
+ seen, flat = set(), []
294
+ for _, edge_list in _views(window, axis).values():
295
+ for e in edge_list:
296
+ if id(e) not in seen:
297
+ seen.add(id(e))
298
+ flat.append(e)
299
+ return flat
300
+
301
+
302
+ def snapshot_edges(window, axis):
303
+ """One gesture's local edge positions, shared by Melty and GLFW replay."""
304
+ _ensure_window_state(window)
305
+ return [(edge, edge[axis]) for edge in _all_edges(window, axis)]
306
+
307
+
308
+ def _cells_from_lists(edge_lists, axis="x", specs=()):
309
+ """The CELLS of every edge list: consecutive pairs (near, far) with the
310
+ cell's floor and cap — from the list's per-view spec ``(floors, caps)``
311
+ when one is given (``specs`` parallel to ``edge_lists``; a None floor
312
+ is the axis minimum, a None cap unbounded), else from the "min" /
313
+ "max" stamped on the far edge (_edge_min / _edge_max). A pair two
314
+ views both register (a shared cell) merges to the strictest floor /
315
+ cap. A cell is the ONLY thing that links two edges — which is what
316
+ makes the columns of different rows independent: their dividers share
317
+ no cell, just the frame edges."""
318
+ cells = {}
319
+ specs = list(specs) + [None] * (len(edge_lists) - len(specs))
320
+ for edges, spec in zip(edge_lists, specs):
321
+ for m in range(1, len(edges)):
322
+ near, far = edges[m - 1], edges[m]
323
+ if near is far:
324
+ continue
325
+ if spec is not None:
326
+ floors, caps = spec
327
+ floor = floors[m - 1] if m - 1 < len(floors) else None
328
+ # None = the axis minimum; an explicit 0.0 is a GAP cell (the
329
+ # OS level's "os_near, W),) contact at zero width
330
+ floor = _axis_min(axis) if floor is None else float(floor)
331
+ cap = caps[m - 1] if m - 1 < len(caps) else None
332
+ # None = unbounded; an explicit 0.0 is a RIGID cell (the
333
+ # pinned root's link to the OS frame: os_frame.gap_cell)
334
+ cap = None if cap is None else max(float(cap), floor)
335
+ else:
336
+ floor, cap = _edge_min(edges, m, axis), _edge_max(edges, m)
337
+ key = (id(near), id(far))
338
+ if key in cells:
339
+ _near, _far, floor0, cap0 = cells[key]
340
+ floor = max(floor, floor0)
341
+ if cap is None:
342
+ cap = cap0
343
+ elif cap0 is not None:
344
+ cap = min(cap, cap0)
345
+ cells[key] = (near, far, floor, cap)
346
+ return list(cells.values())
347
+
348
+
349
+ def _detached_outer_edges(window, axis):
350
+ """``{id(edge): (edge, "near" | "far")}`` — every layout's OUTER edge
351
+ (its first / last) that is DETACHED: neither the window's frame edge
352
+ nor an edge any other list on the window shares (a nested layout's
353
+ adopted cell edges are shared, a layout built with its own
354
+ ``top_edge=`` / ``bottom_edge=`` dicts is not — the chat sidebar's
355
+ rows, 09-13). Such an edge is the layout's own boundary, re-dictated
356
+ on every build (see _window_graph), so to the hand it is where the
357
+ window ends: _edge_under_cursor lands a right-drag resize on the frame
358
+ edge behind it instead of on the dict itself."""
359
+ fe = _frame(window, axis) or ()
360
+ frame_ids = {id(e) for e in fe}
361
+ views = _views(window, axis)
362
+ seen = {}
363
+ for key, (ds, edges) in views.items():
364
+ if key == window.id:
365
+ continue
366
+ for e in edges:
367
+ seen[id(e)] = seen.get(id(e), 0) + 1
368
+ detached = {}
369
+ for key, (ds, edges) in views.items():
370
+ if key == window.id or len(edges) < 2:
371
+ continue
372
+ for e, side in ((edges[0], "near"), (edges[-1], "far")):
373
+ if id(e) in frame_ids or seen.get(id(e), 0) != 1:
374
+ continue
375
+ detached[id(e)] = (e, side)
376
+ return detached
377
+
378
+
379
+ def _window_graph(window, axis, extra_lists=(), extra_specs=()):
380
+ """The cell graph of every layout registered on the window for
381
+ ``axis`` (per-view specs where the layouts stamped them), plus
382
+ ``extra_lists`` / ``extra_specs`` — the OS level's cells a root
383
+ window's pass adds for the solve only (os_frame.attach). A layout's
384
+ DETACHED outer edge (_detached_outer_edges) is deliberately NOT linked
385
+ to the frame: a layout that hands its far edges in re-dictates their
386
+ positions every frame (the chat sidebar builds them from the flow
387
+ origin), so a push the solve gave such an edge was thrown away on the
388
+ next build and the divider only jittered against the clamp (09-13).
389
+ The push stops at the layout's floor there; the right-drag RESIZE
390
+ gesture reaches the frame through _edge_under_cursor instead."""
391
+ views, specs = _views(window, axis), _specs(window, axis)
392
+ keys = list(views)
393
+ lists = [views[k][1] for k in keys] + list(extra_lists)
394
+ spec_list = [specs.get(k) for k in keys] + list(extra_specs)
395
+ return edge_constraints.EdgeGraph(_cells_from_lists(lists, axis, specs=spec_list))
396
+
397
+
398
+ def _drag_edge(edges, k, target, walls=frozenset(), axis="x"):
399
+ """Move edge k of ONE ordered edge list to ``target`` — the single-list
400
+ case of edge_constraints.solve_edge (consecutive edges of one list are its cells);
401
+ _clamp_interior's cap repair runs through here."""
402
+ graph = edge_constraints.EdgeGraph(_cells_from_lists([edges], axis))
403
+ edge_constraints.solve_edge(graph, edges[k], target, walls=walls, axis=axis)
404
+
405
+
406
+ def _replay_hand_drags(window, axis, pending, os_ctx):
407
+ """STICKY hand drags — the feel of Hyprland's right-drag resize, for
408
+ every drag the edge system takes (Lukas 09-13): a divider, a frame
409
+ handle, a right-drag latch, in a studio window or an app's root. The
410
+ first frame of a gesture (a cursor-driven entry while a button is
411
+ held) SNAPSHOTS what this axis's solve can touch: every registered
412
+ edge of the window, its position and size, and the OS edges of the
413
+ frame's link (os_ctx). Every following frame restores that snapshot
414
+ and re-solves with the gesture's ACCUMULATED total as an absolute
415
+ target — so a step the wall or a floor swallowed is not lost, a flip
416
+ unwinds, pushed neighbours and the OS window come back, and the
417
+ original state returns exactly when the hand does. One mechanism in
418
+ the one solve; no per-feature bookkeeping.
419
+
420
+ The snapshot is kept in SCREEN terms where the surface can move under
421
+ a window: a root's window_pos (a free root holds its screen position
422
+ when the surface moves, apply_rebase) and a frame-pinned root's edges
423
+ (its content origin IS the surface's) are converted through
424
+ os_frame.applied_origin each frame; a nested window is parent-relative
425
+ and restores plainly. Foreign (non-cursor) entries pass through
426
+ untouched. The gesture ends when no mouse button is held."""
427
+ import meltygui.core.windowing.os_frame as os_frame
428
+ gestures = getattr(window, "_edge_gestures", None)
429
+ if gestures is None:
430
+ gestures = window._edge_gestures = {}
431
+ hand = [item for item in pending if len(item) > 2 and bool(item[2])]
432
+ # A hand MOVE of this window (os_ctx.move) is a gesture too: its push
433
+ # of the OS edge is re-derived from the window's current position
434
+ # and applied against the OS edges as they were when the move began,
435
+ # so an edge pushed out slides BACK as the window comes back (the
436
+ # window's own position is already absolute, press-anchored).
437
+ # Only a window moved by ITS OWN hand (the wrapper's move drag has
438
+ # _hand_move_frame): os_ctx.move is also set for a window riding an
439
+ # ancestor's hand move / resize (_hand_moved), and replaying THAT as
440
+ # a gesture restored the OS edges to the child's move snapshot right
441
+ # after the parent's solve had pushed them: the push was undone every
442
+ # other frame and the app crawled 16 px per 200 of hand whenever a
443
+ # context menu was open (09-13). The ancestor's move gesture replays
444
+ # the OS edges; the child just rides.
445
+ from meltygui.core.melty import Melty
446
+ move = (os_ctx is not None and bool(getattr(os_ctx, "move", False))
447
+ and getattr(window, "_hand_move_frame", None) == Melty.frame_count)
448
+ released = not os_frame._any_button_down()
449
+ if released and ((not hand and not move) or gestures.get(axis) is None):
450
+ # the hand let go and nothing of its gesture is left to apply
451
+ gestures.pop(axis, None)
452
+ return pending
453
+ if not hand and not move:
454
+ return pending
455
+ # (a release frame that still carries a gesture's last increment
456
+ # replays it - dropped, the last step landed incrementally and the
457
+ # OS edges stayed one step short of the return, 09-13 - and pops after)
458
+ i = 0 if axis == "x" else 1
459
+ # Every surface-bound frame rides the native origin. Its local edges
460
+ # replay in that space; its caller-owned position must never be restored
461
+ # as though it belonged to a free floating window.
462
+ binding = os_ctx.binding if os_ctx is not None else os_frame.frame_binding(window, axis)
463
+ pinned = binding is not None
464
+ parent = os_frame._frame_parent(window)
465
+ is_root = parent is None
466
+ # Whose coordinates move with the SURFACE: a root's (free: apply_rebase
467
+ # re-bases its window_pos to hold the screen; pinned: its edges ride
468
+ # the origin) and — the studio roots of an app — a pinned root's
469
+ # child with no intervening window, re-based exactly like a free root
470
+ # (os_frame._rebased_windows). Restored plainly, that child lost every
471
+ # re-base the flip's push had just earned: the solve pushed the OS
472
+ # edge along restored it, the push doubled frame after frame and the app
473
+ # grew to the whole display in four frames (Lukas 09-13). A nested
474
+ # window under a FREE root is parent-relative and restores plainly.
475
+ # Caller-controlled workspace frames also follow native layout. Their
476
+ # descendants restore against the measured anchor, so sticky replay does
477
+ # not undo the compensation applied after that layout changes.
478
+ rides_surface = is_root or os_frame._native_layout_parent(window) is not None
479
+ # The CORNER the window's coordinates hang from, in SCREEN terms: the
480
+ # surface's applied origin (os_ctx.base is near - unapplied + the
481
+ # window's screen position, os_frame.attach - shift through it; never
482
+ # off the OS dicts, which sit SHIFTED into window coordinates for the
483
+ # whole solve), or, for a pinned root's child hung from the far corner
484
+ # (a context menu), the OS FAR edge itself: compensate_far moves such
485
+ # a child by every move of that edge, and restoring its position
486
+ # against the origin undid the compensation - it jumped right by all
487
+ # it had pushed, pushed by that again, and the app ran off (09-13).
488
+ hangs_far = (not is_root and rides_surface and os_frame._driver_of(window, axis) == "far")
489
+ if os_ctx is None or not rides_surface:
490
+ origin_now = 0.0
491
+ elif not is_root and os_frame._has_measured_anchor(window):
492
+ base = os_frame._anchor_base(window)
493
+ origin_now = os_ctx.base - os_frame._screen_pos(window, axis) + base[i]
494
+ elif hangs_far:
495
+ origin_now = os_ctx.native[1][axis] + os_ctx.base # the OS far edge, screen coords
496
+ else:
497
+ origin_now = os_ctx.base - os_frame._screen_pos(window, axis)
498
+ gesture = gestures.get(axis)
499
+ if gesture is not None and gesture.get("generation") != os_frame._STATE["generation"]:
500
+ # The geometry feed disappeared/returned or changed shape. Its
501
+ # screen origin is a new coordinate space; keep the current layout
502
+ # and start a new gesture instead of replaying the old origin.
503
+ gesture = None
504
+ if gesture is None:
505
+ snap = {}
506
+ edges = {}
507
+ for edge, value in snapshot_edges(window, axis):
508
+ edges[id(edge)] = edge
509
+ snap[id(edge)] = value
510
+ for e in (_frame(window, axis) or ()):
511
+ edges[id(e)] = e
512
+ snap[id(e)] = e[axis]
513
+ pos = window.window_pos[i] if window.window_pos is not None else None
514
+ gesture = gestures[axis] = {
515
+ "generation": os_frame._STATE["generation"],
516
+ "edges": edges, "snap": snap, "totals": {},
517
+ "pos": pos, "size": float(window.width if axis == "x" else window.height),
518
+ "origin": origin_now,
519
+ "os": None,
520
+ }
521
+ if gesture["os"] is None and os_ctx is not None:
522
+ gesture["os"] = {id(os_ctx.sources[id(e)]): e[axis] + os_ctx.base for e in os_ctx.shifted
523
+ if id(e) in os_ctx.os_ids and id(e) not in os_ctx.walls}
524
+ if not hand and not gesture["totals"]:
525
+ # a pure move gesture: only the OS edges are replayed; the push
526
+ # block in _solve_collisions re-derives the contact from where the
527
+ # move put the window this frame
528
+ if gesture["os"]:
529
+ for e in os_ctx.shifted:
530
+ if id(os_ctx.sources[id(e)]) in gesture["os"] and id(e) not in os_ctx.walls:
531
+ e[axis] = gesture["os"][id(os_ctx.sources[id(e)])] - os_ctx.base
532
+ if released:
533
+ gestures.pop(axis, None)
534
+ return pending
535
+ # accumulate this frame's increments (an edge born mid-gesture joins at zero value)
536
+ for edge, target, _cursor in hand:
537
+ eid = id(edge)
538
+ if eid not in gesture["snap"]:
539
+ gesture["edges"][eid] = edge
540
+ gesture["snap"][eid] = edge[axis]
541
+ gesture["totals"][eid] = gesture["totals"].get(eid, 0.0) + (target - edge[axis])
542
+ # restore the snapshot
543
+ shift = (gesture["origin"] - origin_now) if rides_surface else 0.0
544
+ edge_shift = shift if pinned else 0.0
545
+ for eid, e in gesture["edges"].items():
546
+ e[axis] = gesture["snap"][eid] + edge_shift
547
+ if not pinned and gesture["pos"] is not None and window.window_pos is not None:
548
+ pos = list(window.window_pos)
549
+ moved = gesture["pos"] + shift - pos[i]
550
+ pos[i] = gesture["pos"] + shift
551
+ window.window_pos = tuple(pos)
552
+ if os_ctx is not None and moved:
553
+ # Replay changed the window origin. Translate the context's
554
+ # private edges by the same amount so their screen coordinates
555
+ # and the native result remain unchanged.
556
+ os_ctx.base += moved
557
+ # Keep current external walls in screen coordinates when the
558
+ # replay changes the origin used by attach/detach.
559
+ for e in os_ctx.shifted:
560
+ e[axis] -= moved
561
+ if axis == "x":
562
+ window.width = gesture["size"]
563
+ else:
564
+ window.height = gesture["size"]
565
+ if os_ctx is not None and gesture["os"]:
566
+ for e in os_ctx.shifted:
567
+ if id(os_ctx.sources[id(e)]) in gesture["os"] and id(e) not in os_ctx.walls:
568
+ e[axis] = gesture["os"][id(os_ctx.sources[id(e)])] - os_ctx.base
569
+ # absolute targets from the gesture's start
570
+ replayed = []
571
+ for item in pending:
572
+ if len(item) > 2 and bool(item[2]):
573
+ edge = item[0]
574
+ eid = id(edge)
575
+ replayed.append((edge, gesture["snap"][eid] + edge_shift + gesture["totals"][eid], True))
576
+ else:
577
+ replayed.append(item)
578
+ # an edge dragged early in the gesture but idle this frame keeps its total
579
+ active = {id(item[0]) for item in hand}
580
+ for eid, total in gesture["totals"].items():
581
+ if eid not in active and total and eid in gesture["edges"]:
582
+ replayed.append((gesture["edges"][eid], gesture["snap"][eid] + edge_shift + total, True))
583
+ if released:
584
+ gestures.pop(axis, None)
585
+ return replayed
586
+
587
+
588
+ def _solve_collisions(window, axis="x", os_ctx=None):
589
+ """Apply every queued drag of ``axis`` against the FULL cell graph of
590
+ that axis — every registered edge list's cells at once — so contact
591
+ chains cross view boundaries through shared edges and nothing else.
592
+ Returns True if anything moved. (No standing repair pass: edges only
593
+ move while a drag is applied.)
594
+
595
+ ``os_ctx`` (os_frame.attach, root windows only) adds the OS level to
596
+ the graph: the OS window's frame pair, the screen walls, and two
597
+ zero-floor gap cells linking this window's frame to the OS frame — so
598
+ a cursor-driven drag pushed through the frame moves the OS edge, and
599
+ the OS edge pushed into the screen is clamped like any wall. Its own
600
+ drags (the OS window's frame edges, foreign motion since this window
601
+ last saw them) ride along in the same solve. Only the HAND moves the
602
+ OS window: a foreign size write of this window (its content grew)
603
+ sees the OS edges as walls.
604
+
605
+ THE FLIP: a cursor-driven drag whose owner is blocked by a wall grows
606
+ that window on the OPPOSITE side by the remainder — the rule meltygui
607
+ windows always had against the display ("pin the bottom, let the top
608
+ rise"), now for the OS window too, and the only way a near edge ever
609
+ moves outward on its own: W's right edge blocked at the screen → W's
610
+ left edge moves left → pushes the OS left edge → the OS window grows
611
+ left (and moves) → the screen's left edge stops it."""
612
+ import meltygui.core.windowing.os_frame as os_frame
613
+ pending_attr = _REGISTRY[axis][1]
614
+ pending = getattr(window, pending_attr)
615
+ setattr(window, pending_attr, [])
616
+ if os_ctx is not None and os_ctx.binding is not None and os_ctx.drags:
617
+ # The native frame already overlaps this root's contacts through
618
+ # the OS edge links. Its width is a fixed value, not the far edge's
619
+ # coordinate while the near edge is moving. Replaying right=width
620
+ # after the native edge drags applies that near motion twice and
621
+ # bypasses the column contacts the native drag just resolved.
622
+ # Synchronize to the native far edge in this graph's coordinates.
623
+ frame = _frame(window, axis) or ()
624
+ if len(frame) == 2:
625
+ pending = [(item[0], os_ctx.os_far0 - os_ctx.base - os_ctx.binding.far_gap, *item[2:])
626
+ if item[0] is frame[1] and not (len(item) > 2 and bool(item[2]))
627
+ else item for item in pending]
628
+ pending = _replay_hand_drags(window, axis, pending, os_ctx) # sticky: targets from the gesture's start
629
+ os_items = list(os_ctx.drags) if os_ctx is not None else []
630
+ if not pending and not os_items and not (os_ctx is not None and os_ctx.move):
631
+ return False
632
+ fe = _frame(window, axis) or ()
633
+ local_graph = _window_graph(window, axis)
634
+ if os_ctx is not None and len(fe) == 2:
635
+ gap_lists, gap_specs = os_frame.gap_lists(os_ctx, fe[0], fe[1])
636
+ os_graph = _window_graph(window, axis, extra_lists=list(os_ctx.lists) + gap_lists,
637
+ extra_specs=list(os_ctx.specs) + gap_specs)
638
+ base_walls, os_ids = os_ctx.walls, os_ctx.os_ids
639
+ os_pair = os_ctx.native
640
+ else:
641
+ os_graph, base_walls, os_ids, os_pair = local_graph, frozenset(), frozenset(), None
642
+ # A FRAME edge drag must never shove the OTHER frame edge. The danger
643
+ # case is the foreign-width invariant drag (window_edge_pass queues
644
+ # right→window.width): when an outside writer re-stamps width below the
645
+ # fully-compressed pile span every frame, that drag pushes clean through
646
+ # the pile into the left frame edge, the rebase dumps the mismat
647
+ # into window_pos, width springs back, and the loop re-fires each frame
648
+ # - the window flies off screen with no end limit. Walling the
649
+ # opposite frame edge clamps the drag at the pile span instead, so the
650
+ # mismatch resolves by width snapping back (stable), never by sliding.
651
+ # INTERIOR divider drags keep an empty wall set: pushing the window's
652
+ # left edge with a divider (slide or grow) is normal normal and only
653
+ # ever moves 1:1 with the cursor.
654
+ frame_ids = {id(e) for e in fe}
655
+ # ``moved`` = THIS window's frame moved. An OS edge moving on its own
656
+ # (the OS window's frame, a foreign move every root folds in) is not a
657
+ # change of this window - and reporting it as one invalidated every
658
+ # view of every root on every frame of an OS-window gesture (08-27:
659
+ # 120 → 65 fps on the corner, 12 fps on a move). The OS level reaches
660
+ # this window's interior only THROUGH its frame pair, so the frame
661
+ # pair before/after tells.
662
+ frame_before = tuple(e[axis] for e in fe)
663
+ moved = False
664
+ if os_ctx is not None and os_ctx.move and os_pair is not None and len(fe) == 2:
665
+ # A HAND MOVE of this window (its position already applied by the
666
+ # wrapper's move drag): the OS edge its frame now overlaps is
667
+ # pushed out to it — up to the screen, where the OS edge stops
668
+ # and the window keeps moving (a move is not clamped: windows
669
+ # may be dragged partly off the display, Lukas 08-27 - not
670
+ # above the screen limit, clamped after the solve in _frame_pass,
671
+ # Toggles.Melty.window_top_hard_limit). The push is
672
+ # the OS edge pushed to the window's edge with the screen as the
673
+ # wall; the window's own edges are not part of it.
674
+ os_near, os_far = os_pair
675
+ if os_far[axis] < fe[1][axis]:
676
+ edge_constraints.solve_edge(os_graph, os_far, fe[1][axis], walls=base_walls, axis=axis)
677
+ if os_near[axis] > fe[0][axis]:
678
+ edge_constraints.solve_edge(os_graph, os_near, fe[0][axis], walls=base_walls, axis=axis)
679
+ for item in os_items + pending:
680
+ # Optional third slot marks a CURSOR-DRIVEN drag (the right-drag
681
+ # corner resize queues frame edges around it): those move 1:1 with the
682
+ # cursor, so the foreign-width feedback loop the walls guard against
683
+ # can't occur - a cursor drag on one frame edge is allowed to push
684
+ # the other (slide the window), just like an interior divider.
685
+ edge, target = item[0], item[1]
686
+ cursor_driven = len(item) > 2 and bool(item[2])
687
+ is_os = os_pair is not None and (edge is os_pair[0] or edge is os_pair[1])
688
+ # Only the HAND moves the OS window: a foreign size write of this
689
+ # window (its content grew) solves in its own graph, so it
690
+ # overflows the screen window as intended instead of being
691
+ # clamped by it or growing the studio.
692
+ graph = os_graph if (cursor_driven or is_os) else local_graph
693
+ walls = set(base_walls) # the screen never moves
694
+ if not cursor_driven:
695
+ if is_os:
696
+ walls |= os_ids - {id(edge)} # a foreign OS edge IS where it is; the other holds
697
+ elif id(edge) in frame_ids:
698
+ walls |= frame_ids - {id(edge)}
699
+ walls = frozenset(walls)
700
+ if cursor_driven and id(edge) in frame_ids:
701
+ # The cap is a STOP, not a slide: clamp the cursor's target at
702
+ # max_height BEFORE the solve. Solved at the raw target and
703
+ # pulled back by _hold_frame_max after, the residual flipped
704
+ # onto the opposite edge (the min hold's slide) - the window
705
+ # kept moving while its height stayed capped (Lukas 09-04).
706
+ target = _cap_frame_target(window, edge, target, axis)
707
+ if edge_constraints.solve_edge(graph, edge, target, walls=walls, axis=axis) and not is_os:
708
+ moved = True
709
+ if is_os and not cursor_driven:
710
+ # A foreign OS edge IS where it is: the pile packed against it
711
+ # as much as it could (the wall clamp), the rest overflows.
712
+ # Forced HERE, before this window's own drags run - forced
713
+ # after the solve (detach) it ate away the push those drags
714
+ # gave the same edge, so the OS edge advanced only every other
715
+ # frame, with a double step (one sub-pixel the begin_frame gives
716
+ # the far edge when a drag lands on every frame "moves").
717
+ edge[axis] = float(target)
718
+ if not cursor_driven:
719
+ continue
720
+ if id(edge) in frame_ids:
721
+ _hold_frame_min(window, edge, axis)
722
+ _hold_frame_max(window, edge, axis)
723
+ residual = target - edge[axis]
724
+ if abs(residual) <= 1e-6:
725
+ continue
726
+ pair = os_pair if is_os else (fe if len(fe) == 2 else None)
727
+ if pair is None:
728
+ continue
729
+ opposite = pair[0] if residual > 0 else pair[1]
730
+ if opposite is edge: # dragged inward, blocked: nothing to flip
731
+ continue
732
+ edge_constraints.solve_edge(graph, opposite, opposite[axis] - residual, walls=walls, axis=axis)
733
+ if tuple(e[axis] for e in fe) != frame_before:
734
+ moved = True
735
+ return moved
736
+
737
+
738
+ def _hold_frame_min(window, dragged, axis="x"):
739
+ """A cursor-driven FRAME edge dragged past the window's min_width
740
+ (min_height on the row axis) pushes the OTHER frame edge along — the
741
+ window slides (right edge dragged left) or grows (left edge dragged
742
+ right) — instead of stopping short of the cursor. Without this the
743
+ solve left the frame pair narrower than min_width, the wrapper's
744
+ `width = max(width, min_width)` re-stamp (after the pass) widened it
745
+ again, and the next pass's foreign-width invariant dragged the cursor
746
+ edge BACK every frame: the right edge yanked between the cursor and
747
+ min_width while the left edge kept sliding, width ratcheting up
748
+ mid-drag. Interior edges need no cascade: the pushed edge only ever
749
+ moves AWAY from them."""
750
+ fe = _frame(window, axis)
751
+ if not fe:
752
+ return
753
+ near, far = fe
754
+ floor = float((window.min_width if axis == "x" else window.min_height) or 0)
755
+ if far[axis] - near[axis] >= floor:
756
+ return
757
+ if dragged is far:
758
+ near[axis] = far[axis] - floor
759
+ else:
760
+ far[axis] = near[axis] + floor
761
+
762
+
763
+ def _cap_frame_target(window, dragged, target, axis="x"):
764
+ """`target` for a cursor-driven FRAME edge, held within the window's
765
+ max_height (row axis only): the far edge no farther than near + cap,
766
+ the near edge no nearer than far − cap. Other axes / no cap: as is."""
767
+ if axis != "y":
768
+ return target
769
+ cap = getattr(window, "max_height", None)
770
+ if not cap:
771
+ return target
772
+ fe = _frame(window, axis)
773
+ if not fe:
774
+ return target
775
+ near, far = fe
776
+ if dragged is far:
777
+ return min(target, near[axis] + float(cap))
778
+ if dragged is near:
779
+ return max(target, far[axis] - float(cap))
780
+ return target
781
+
782
+
783
+ def _hold_frame_max(window, dragged, axis="x"):
784
+ """The mirror of _hold_frame_min for the window's max_height (the row
785
+ axis only — there is no max_width): a cursor-driven FRAME edge dragged
786
+ past the cap STOPS at it — the dragged edge is pulled back to
787
+ cap-distance from the other, which never moves (the usage picker's
788
+ content-height ceiling, enforced mid-drag, Lukas 09-04)."""
789
+ if axis != "y":
790
+ return
791
+ cap = getattr(window, "max_height", None) # test stand-ins lack the slot
792
+ if not cap:
793
+ return
794
+ fe = _frame(window, axis)
795
+ if not fe:
796
+ return
797
+ near, far = fe
798
+ if far[axis] - near[axis] <= cap:
799
+ return
800
+ if dragged is far:
801
+ far[axis] = near[axis] + float(cap)
802
+ else:
803
+ near[axis] = far[axis] - float(cap)
804
+
805
+
806
+ def _drag_live():
807
+ """Mirror of the frozen-blit gate's activity test in blit_offscreen: a
808
+ mouse drag (any button) is in flight. Programmatic edge moves (foreign
809
+ width writes) fall outside it, so they still invalidate normally."""
810
+ from meltygui.core.melty import Melty
811
+ return Melty.resize_gesture_live()
812
+
813
+
814
+ def _defer_freeze_settle(window, draw_state):
815
+ """Record a freeze_resize view whose per-frame edge invalidate was
816
+ skipped mid-drag; window_edge_pass settles it once on release."""
817
+ pending = getattr(window, "_freeze_settle", None)
818
+ if pending is None:
819
+ pending = window._freeze_settle = {}
820
+ pending[id(draw_state)] = draw_state
821
+
822
+
823
+ def release_row(draw_state, keep=()):
824
+ """Drop this host's registered edge rows (both axes) — except the keyed
825
+ layouts named in ``keep`` (an outer layout the host built in the same
826
+ frame around the rows it releases). Hosts that
827
+ sometimes render WITHOUT columns (the code editor leaving a compare
828
+ split) must call this on their column-less frames: window_edge_pass
829
+ only evicts rows whose ds is CLOSED, and a host whose ds IS its window
830
+ never closes while open — the stale row keeps feeding edge_under_cursor
831
+ and the frame-edge solve, so right-drag resizes latch an invisible
832
+ stale divider and the window width freezes. ColumnLayout / RowLayout
833
+ re-register on construction, so releasing before building them later
834
+ in the same frame is safe."""
835
+ window = layout_window(draw_state)
836
+ for axis, kind in (("x", "row"), ("y", "rows")):
837
+ for attr in (_REGISTRY[axis][0], _REGISTRY[axis][3], _REGISTRY[axis][4]):
838
+ table = getattr(window, attr, None)
839
+ if table is None:
840
+ continue
841
+ # The host entry AND every keyed one of this host (a keyed
842
+ # layout's key is (kind, ds.id, key)).
843
+ for k in [k for k in table
844
+ if isinstance(k, tuple) and len(k) >= 2
845
+ and k[0] == kind and k[1] == draw_state.id
846
+ and not (len(k) > 2 and k[2] in keep)]:
847
+ del table[k]
848
+ draw_state._column_container = False
849
+ draw_state._row_container = False
850
+
851
+
852
+ def _has_layout_ancestor(draw_state, flag):
853
+ """True when another layout container carrying ``flag``
854
+ (``_column_container`` / ``_row_container``) sits between this view
855
+ and its window. Used to decide window-frame adoption: a row with NO
856
+ such ancestor is the window's row — however many plain wrapper views
857
+ (window bodies, code_file_io, …) sit in between — and adopts the
858
+ window's frame edges on that axis; a row under another container of
859
+ its axis gets its far edges from the enclosing cell instead (passed
860
+ refs, or its own as the deep-nesting fallback). The climb is scoped to
861
+ the parent window and guards the root ds's self-parent loop. A Rows
862
+ host is transparent to a Columns view and vice versa: the axes don't
863
+ share edges."""
864
+ window = layout_window(draw_state)
865
+ if window is draw_state:
866
+ return False
867
+ node = draw_state._parent
868
+ while node is not None and node is not node._parent:
869
+ if node is window:
870
+ break
871
+ if getattr(node, flag, False):
872
+ return True
873
+ node = node._parent
874
+ return False
875
+
876
+
877
+ def _has_columns_ancestor(draw_state):
878
+ return _has_layout_ancestor(draw_state, "_column_container")
879
+
880
+
881
+ def _has_rows_ancestor(draw_state):
882
+ return _has_layout_ancestor(draw_state, "_row_container")
883
+
884
+
885
+ def _register_frame_handles(window):
886
+ """Hit regions and cursors use the same committed rectangle as drawing."""
887
+ win_x, win_y = window.abs_left, window.abs_top
888
+ half = EDGE_GRAB_WIDTH / 2
889
+ for axis in _REGISTRY:
890
+ for k, edge in enumerate(_frame(window, axis) or ()):
891
+ if axis == "x":
892
+ x = win_x + edge[axis]
893
+ rect = (x-half, win_y, x+half, win_y+window.height)
894
+ cursor = mouse_cursor.RESIZE_EW
895
+ else:
896
+ y = win_y + edge[axis]
897
+ rect = (win_x, y-half, win_x+window.width, y+half)
898
+ cursor = mouse_cursor.RESIZE_NS
899
+ view_id = f"win_edge_{axis}_{k}"
900
+ window.on_action("left_mouse_drag", view_id=view_id, rect=rect,
901
+ priority_delta=1, cursor=cursor)
902
+ window.on_action("left_mouse_down", view_id=view_id, rect=rect,
903
+ priority_delta=1)
904
+
905
+
906
+ def window_edge_pass(window):
907
+ """Every window's FRAME edges — left/right on the x axis, top/bottom on
908
+ the y axis — as draggable edge objects, run once per frame per window
909
+ (idempotent; called from core_render's meltygui-window path for ALL
910
+ windows, and from the layouts as a fallback).
911
+
912
+ Per axis the window owns two edge dicts seeded at [0, size] (window
913
+ coords) and registered in the same solve as every column / row edge.
914
+ Native resizes (corner drag, right-click draw, programmatic size
915
+ writes) are folded in through the invariant far.pos == window.size:
916
+ any foreign size change queues a drag of the far frame edge, so it runs
917
+ the SAME collision solve and frame and lines can never desync. After
918
+ the solve the window lines up with its edges exactly like cells do:
919
+ near edge off 0 → window_pos slides + everything re-bases; far edge off
920
+ the size → the size follows."""
921
+ from meltygui.core.melty import Melty
922
+ if getattr(window, "_edges_frame", None) == Melty.frame_count:
923
+ return
924
+ window._edges_frame = Melty.frame_count
925
+
926
+ if not getattr(window, "expanded", True) or not window.width or not window.height:
927
+ return
928
+ _ensure_window_state(window)
929
+
930
+ # freeze_resize views whose per-frame edge invalidates were skipped
931
+ # mid-drag (see the edge-solve loop below) settle with ONE invalidate on
932
+ # release - box-unchanged views have no size mismatch to trigger the
933
+ # normal settled resize recapture, so without it they'd blit stale
934
+ # forever.
935
+ pending_settle = getattr(window, "_freeze_settle", None)
936
+ if pending_settle and not _drag_live():
937
+ for ds in pending_settle.values():
938
+ if not getattr(ds, "closed", False) and not ds.size_change:
939
+ ds.invalidate(note=Note(reason="freeze settle", **_NOTE))
940
+ window._freeze_settle = {}
941
+ request_render()
942
+
943
+ moved = _frame_pass(window, "x")
944
+ moved = _frame_pass(window, "y") or moved
945
+ _register_frame_handles(window)
946
+
947
+ if moved:
948
+ seen = set()
949
+ for axis in _REGISTRY:
950
+ for ds, _ in _views(window, axis).values():
951
+ if id(ds) in seen:
952
+ continue
953
+ seen.add(id(ds))
954
+ # A view whose box already disagrees with its tile
955
+ # (size_change) is live-rendered by blit_offscreen as-is -
956
+ # cached image and cached masks are both refused mid-resize,
957
+ # and the tile is rebuilt once on mouse release.
958
+ # Invalidating it here would only queue per-frame tile
959
+ # copies at the wrong size. Only views whose own box is
960
+ # UNCHANGED while their edges move (interior layout drags)
961
+ # still need the explicit invalidate or they blit stale.
962
+ # freeze_resize views are the exception BOTH ways: mid-drag
963
+ # they WANT to blit stale (that's the freeze contract -
964
+ # invalidating would force a live body re-render every drag
965
+ # frame, since a box-unchanged view never hits the size
966
+ # size-mismatch gate), so their invalidate is deferred to the
967
+ # release settle above.
968
+ if not ds.size_change:
969
+ if getattr(ds, "freeze_resize", False) and _drag_live():
970
+ _defer_freeze_settle(window, ds)
971
+ else:
972
+ ds.invalidate(note=Note(reason="edge solve", **_NOTE))
973
+
974
+
975
+ def _hand_moved(window, frame):
976
+ """Was ``window`` moved by hand this frame — itself (the move drag
977
+ stamps ``_hand_move_frame``) or through an ANCESTOR it rides with (a
978
+ nested window's window_pos is parent-relative: dragging the parent
979
+ moves the child on screen, and the child's frame must push the OS
980
+ edge it reaches exactly as the parent's does)."""
981
+ node, depth = window, 0
982
+ while node is not None and depth < 64:
983
+ if getattr(node, "_hand_move_frame", None) == frame:
984
+ return True
985
+ # An ANCESTOR resized by hand this frame (_frame_pass stamps
986
+ # _hand_resize_frame) moves the cell the child hangs from, so the
987
+ # child rides exactly as under a hand move and must collide with
988
+ # the OS edges the same way. The window's OWN resize is not a move
989
+ # of it (its frame drags solve in its pass already). A frame-pinned
990
+ # root's resize already collided with the child on the OS graph.
991
+ if (node is not window and getattr(node, "_hand_resize_frame", None) == frame
992
+ and not getattr(node, "_frame_pinned", False)):
993
+ return True
994
+ node = getattr(node, "parent_window", None)
995
+ depth += 1
996
+ return False
997
+
998
+
999
+ def _frame_pass(window, axis):
1000
+ """One axis of window_edge_pass: seed / register the frame pair, fold
1001
+ the foreign size change in, floor the window's minimum at the pile,
1002
+ offer the frame drag handles, solve, and line the window up with its
1003
+ frame edges. Returns True when any edge of the axis moved."""
1004
+ from meltygui.core.melty import Melty
1005
+ from meltygui.core.runtime.toggles import Toggles
1006
+ frame_attr = _REGISTRY[axis][2]
1007
+ size = window.width if axis == "x" else window.height
1008
+ fe = getattr(window, frame_attr, None)
1009
+ if not fe:
1010
+ fe = [{axis: 0.0}, {axis: float(size)}]
1011
+ setattr(window, frame_attr, fe)
1012
+ views, specs = _views(window, axis), _specs(window, axis)
1013
+ views[window.id] = (window, fe)
1014
+ # The frame pair is a cell too (it keeps the window's own span in the
1015
+ # graph): floored at the window's minimum (never below the flat floor)
1016
+ # and uncapped - the stamps on the far edge belong to the LAST column /
1017
+ # row, never to the frame. The floor is what an OS edge pushing the
1018
+ # window meets (os_frame): the window compresses to it, then slides.
1019
+ declared = float((window.min_width if axis == "x" else window.min_height) or 0)
1020
+ specs[window.id] = ([max(_axis_min(axis), declared)], [None])
1021
+ near, far = fe
1022
+
1023
+ bands = _bands(window, axis)
1024
+ for key, (ds, _) in list(views.items()):
1025
+ # A window body owns its own layouts. Evict registrations left on
1026
+ # its former parent after reparenting - a new ownership change.
1027
+ if ds is not window and (getattr(ds, "closed", False)
1028
+ or getattr(ds, "closable", False)):
1029
+ del views[key]
1030
+ specs.pop(key, None)
1031
+ bands.pop(key, None)
1032
+
1033
+ # Foreign size change since last frame → the far frame edge follows it
1034
+ # THROUGH the collision solve. Third slot None = NOT a cursor drag (the
1035
+ # OS-edge hooks below skip it; _solve_collisions keeps its position).
1036
+ if abs(far[axis] - size) > 0.5:
1037
+ _pending(window, axis).append((far, float(size), None))
1038
+
1039
+ # The frame can never out-compress the window: keep min_width /
1040
+ # min_height at the fully-compressed span so a pending shrink always
1041
+ # triggers its collision pass instead of fighting the resize latch.
1042
+ # Raise-only, re-stamped every frame (the wrapper rewrites the minimum
1043
+ # from resolved kwargs each frame).
1044
+ # The compressed span is the LONGEST chain of cell floors from the near
1045
+ # frame edge to the far one - NOT the sum over every edge, which
1046
+ # double-counts layouts stacked in different rows.
1047
+ graph = _window_graph(window, axis)
1048
+ span = graph.chain(near, far)
1049
+ need = snap_int(near[axis] + max(_axis_min(axis), span or 0.0))
1050
+ if axis == "x":
1051
+ if (window.min_width or 0) < need:
1052
+ window.min_width = need
1053
+ elif (window.min_height or 0) < need:
1054
+ window.min_height = need
1055
+
1056
+ # Delivered events refer to the previous committed hit regions. Consume
1057
+ # them before solving; register the next regions only after both axes.
1058
+ seen_handles = set()
1059
+ total = "total_dx" if axis == "x" else "total_dy"
1060
+ for k, e in enumerate(fe):
1061
+ view_id = f"win_edge_{axis}_{k}"
1062
+ drag = window.get_action("left_mouse_drag", view_id=view_id)
1063
+ press = window.get_action("left_mouse_down", view_id=view_id)
1064
+ if press:
1065
+ Melty.resize_press_frame = Melty.frame_count
1066
+ if not drag:
1067
+ continue
1068
+ handle = f"win_{axis}_{k}"
1069
+ seen_handles.add(handle)
1070
+ inc = _drag_inc(window, handle, drag, total=total)
1071
+ if inc:
1072
+ _pending(window, axis).append((e, e[axis] + inc, True))
1073
+ totals = getattr(window, "_drag_totals", None)
1074
+ if totals:
1075
+ # Prune ONLY this axis's "win_<axis>_*" namespace: when a layout
1076
+ # host ds IS the window (code editor compare split), its divider
1077
+ # handles live in this same dict - deleting them here resets the
1078
+ # divider's drag baseline every frame, so each frame re-applies
1079
+ # the FULL total and the edge flings away from the cursor. The other
1080
+ # axis's frame handles are pruned by its own pass.
1081
+ prefix = f"win_{axis}_"
1082
+ for h in [h for h in totals
1083
+ if isinstance(h, str) and h.startswith(prefix)
1084
+ and h not in seen_handles]:
1085
+ del totals[h]
1086
+
1087
+ # Native/display contact contributes private edges in this window's
1088
+ # coordinates. Free frames have contact gaps; surface-bound frames have
1089
+ # fixed gaps. Only the completed solve publishes native geometry.
1090
+ import meltygui.core.windowing.os_frame as os_frame
1091
+ # The app root's handles resize the GLFW frame. Run that drag through
1092
+ # the OS graph (which contains its floating children), then pack this
1093
+ # root's columns into the resulting size. A local solve alone let the
1094
+ # root push the same OS edge back out in its own pass.
1095
+ binding = os_frame.frame_binding(window, axis)
1096
+ if (binding is not None and os_frame._enabled()
1097
+ and os_frame._STATE["frame"] == Melty.frame_count):
1098
+ pending = _pending(window, axis)
1099
+ frame_drags = [item for item in pending
1100
+ if len(item) > 2 and item[2] and any(item[0] is e for e in fe)]
1101
+ if frame_drags:
1102
+ # A fixed-gap frame edge and its native edge are the same degree
1103
+ # of freedom. Delegate that input; interior edges remain local.
1104
+ for edge, target, _cursor in frame_drags:
1105
+ target = _cap_frame_target(window, edge, target, axis)
1106
+ os_frame.queue_drag(axis, 0 if edge is near else 1, target - edge[axis])
1107
+ pending[:] = [item for item in pending if not any(item is drag for drag in frame_drags)]
1108
+ os_frame.solve(bindings=(binding,))
1109
+ os_near, os_far = os_frame.edges(axis)
1110
+ size = binding.content_size(os_far[axis] - os_near[axis])
1111
+ setattr(window, "width" if axis == "x" else "height", snap_int(size))
1112
+ pending.append((far, size, None))
1113
+ # a cursor-driven drag of THIS window's frame (handle / corner right-drag;
1114
+ # a foreign size write queues None): a hand resize, stamped for the
1115
+ # nested windows that hang off the moved corner (_hand_moved)
1116
+ hand_resize = any(len(item) < 3 or bool(item[2]) for item in _pending(window, axis))
1117
+ import meltygui.core.diagnostics.resize_trace as resize_trace
1118
+ os_ctx = os_frame.attach(window, axis, has_pending=bool(_pending(window, axis)),
1119
+ hand_move=_hand_moved(window, Melty.frame_count), binding=binding)
1120
+ try:
1121
+ trace_solve = bool(_pending(window, axis) or os_ctx is not None)
1122
+ if trace_solve:
1123
+ resize_trace.record("solve-before", window, axis=axis,
1124
+ edges=resize_trace.edges(window, axis),
1125
+ pending=[(id(item[0]), item[1], item[2] if len(item) > 2 else None)
1126
+ for item in _pending(window, axis)],
1127
+ os_base=os_ctx.base if os_ctx is not None else None)
1128
+ moved = _solve_collisions(window, axis, os_ctx)
1129
+ if hand_resize and moved:
1130
+ window._hand_resize_frame = Melty.frame_count
1131
+ except Exception:
1132
+ resize_trace.record("solve-error", window, axis=axis, error=True)
1133
+ raise
1134
+ else:
1135
+ # Only a completed solve publishes native geometry. The context owns
1136
+ # private edge copies, so an exception cannot corrupt another window.
1137
+ if os_ctx is not None:
1138
+ os_frame.detach(window, axis, os_ctx)
1139
+
1140
+ # The display's TOP is a hard limit for a hand move
1141
+ # (Toggles.Melty.window_top_hard_limit): the solve above took the OS edge
1142
+ # as far as the screen lets it, the remainder - the window's top still
1143
+ # above the display top - is clamped by sliding the window back down.
1144
+ # Written to window_pos directly (a near-edge shift through the frame
1145
+ # pair would be a RESIZE: interior edges hold their screen position),
1146
+ # so it is a pure move; the press baseline is untouched, the window
1147
+ # re-tracks the hand if the pointer is moved. Nested windows too, pinned
1148
+ # or not (Lukas 08-28): window_pos is an additive offset on top of the
1149
+ # parent / OS anchor, so the shift holds - the child slides down inside
1150
+ # the parent, the parent is never moved for it.
1151
+ if (axis == "y" and Toggles.Melty.window_top_hard_limit
1152
+ and _hand_moved(window, Melty.frame_count)):
1153
+ limit = os_frame.display_top()
1154
+ top = float(window.abs_top or 0)
1155
+ if top < limit - 1e-6:
1156
+ pos = window.window_pos or (0, 0)
1157
+ window.window_pos = (pos[0], pos[1] + (limit - top))
1158
+
1159
+ # Line the WINDOW up with its frame edges - the same rule cells follow:
1160
+ # near edge off 0 → window_pos slides and every edge re-bases so
1161
+ # interior lines hold their screen position; far edge off the size →
1162
+ # the size snaps. (The OS near edge's own motion re-bases every root
1163
+ # when the surface actually moves - os_frame.apply_rebase, next frame
1164
+ # below - not here.)
1165
+ d_near = near[axis]
1166
+ if d_near:
1167
+ pos = window.window_pos or (0, 0)
1168
+ # The native body stays at its surface content inset. Its native
1169
+ # frame carries the near-edge motion; only local layout edges need
1170
+ # rebasing here. Moving the body too makes content jump until ack.
1171
+ if binding is None:
1172
+ window.window_pos = ((pos[0] + d_near, pos[1]) if axis == "x"
1173
+ else (pos[0], pos[1] + d_near))
1174
+ if axis == "x":
1175
+ window.width = snap_int(window.width - d_near)
1176
+ else:
1177
+ window.height = snap_int(window.height - d_near)
1178
+ for e in _all_edges(window, axis):
1179
+ e[axis] -= d_near
1180
+ moved = True
1181
+ size = window.width if axis == "x" else window.height
1182
+ if abs(far[axis] - size) > 0.5:
1183
+ size = snap_int(far[axis])
1184
+ if axis == "x":
1185
+ window.width = size
1186
+ else:
1187
+ window.height = size
1188
+ moved = True
1189
+ # Kill snap drift so the invariant check doesn't re-fire every frame.
1190
+ far[axis] = float(size)
1191
+ if trace_solve:
1192
+ resize_trace.record("solve-after", window, axis=axis, moved=moved,
1193
+ edges=resize_trace.edges(window, axis))
1194
+ return moved
1195
+
1196
+
1197
+ def reframe_window(window, d_left, d_right, extra_edges=()):
1198
+ """Move the window's LEFT frame edge by ``d_left`` and its RIGHT frame
1199
+ edge by ``d_right`` (screen px, negative = leftward) mid-body, keeping
1200
+ every registered interior edge at its SCREEN position — the same
1201
+ "line the window up with its frame edges" rebase window_edge_pass does
1202
+ post-solve, callable by a body that must change the frame itself (the
1203
+ code editor growing left to keep its main pane in place when a compare
1204
+ split opens). ``extra_edges`` are edge dicts not currently registered
1205
+ (a row released at the top of the body and re-registered later) that
1206
+ must ride along too. Frame edges end up at [0, width] so the next pass
1207
+ sees no foreign change and re-solves nothing. Registered views are
1208
+ invalidated like an edge-solve move (freeze_resize views included:
1209
+ this is a one-shot layout change, not a drag)."""
1210
+ if not d_left and not d_right:
1211
+ return
1212
+ _ensure_window_state(window)
1213
+ fe = getattr(window, "_frame_edges", None)
1214
+ if not fe:
1215
+ fe = [{"x": 0.0}, {"x": float(window.width or 0)}]
1216
+ window._frame_edges = fe
1217
+ pos = window.window_pos or (0, 0)
1218
+ window.window_pos = (pos[0] + d_left, pos[1])
1219
+ window.width = snap_int((window.width or 0) - d_left + d_right)
1220
+ if d_left:
1221
+ seen = set()
1222
+ for e in list(_all_edges(window)) + list(extra_edges):
1223
+ if e is fe[0] or e is fe[1] or id(e) in seen:
1224
+ continue
1225
+ seen.add(id(e))
1226
+ e["x"] -= d_left
1227
+ fe[0]["x"] = 0.0
1228
+ fe[1]["x"] = float(window.width)
1229
+ for ds, _ in window._edge_views.values():
1230
+ if not ds.size_change:
1231
+ ds.invalidate(note=Note(reason="reframe window", **_NOTE))
1232
+ request_render()
1233
+
1234
+
1235
+ def reframe_axis(window, axis, d_near):
1236
+ """Move the window's NEAR frame edge (left for "x", top for "y") by
1237
+ ``d_near`` px (negative = outward) keeping every registered interior
1238
+ edge at its SCREEN position — reframe_window's rule on either axis
1239
+ with the far edge fixed. The glue of the OS-edge handoff (os_frame):
1240
+ the compositor moved the studio's left/top edge, every root window was
1241
+ re-based by the same amount, and the glued window's near edge follows
1242
+ the OS edge back out while its interior stays put."""
1243
+ if not d_near:
1244
+ return
1245
+ _ensure_window_state(window)
1246
+ frame_attr = _REGISTRY[axis][2]
1247
+ size = window.width if axis == "x" else window.height
1248
+ fe = getattr(window, frame_attr, None)
1249
+ if not fe:
1250
+ fe = [{axis: 0.0}, {axis: float(size or 0)}]
1251
+ setattr(window, frame_attr, fe)
1252
+ pos = window.window_pos or (0, 0)
1253
+ if axis == "x":
1254
+ window.window_pos = (pos[0] + d_near, pos[1])
1255
+ window.width = snap_int((window.width or 0) - d_near)
1256
+ size = window.width
1257
+ else:
1258
+ window.window_pos = (pos[0], pos[1] + d_near)
1259
+ window.height = snap_int((window.height or 0) - d_near)
1260
+ size = window.height
1261
+ seen = set()
1262
+ for e in _all_edges(window, axis):
1263
+ if e is fe[0] or e is fe[1] or id(e) in seen:
1264
+ continue
1265
+ seen.add(id(e))
1266
+ e[axis] -= d_near
1267
+ fe[0][axis] = 0.0
1268
+ fe[1][axis] = float(size)
1269
+ for ds, _ in _views(window, axis).values():
1270
+ if not ds.size_change:
1271
+ ds.invalidate(note=Note(reason="reframe axis", **_NOTE))
1272
+ request_render()
1273
+
1274
+
1275
+ def _across_band(ds, axis):
1276
+ """The span a view's edges of ``axis`` cover on the OTHER axis (absolute
1277
+ screen coords): its visible clip when it has one, else its box from the
1278
+ last measured band extent. An x-edge row spans a y band; a y-edge
1279
+ column of rows spans an x band."""
1280
+ clip = getattr(ds, "abs_clip_rect", None)
1281
+ if axis == "x":
1282
+ if clip:
1283
+ return clip[1], clip[3]
1284
+ top = ds.abs_top
1285
+ return top, top + max(getattr(ds, "_edge_lines_height", 0.0),
1286
+ MIN_ROW_HEIGHT)
1287
+ if clip:
1288
+ return clip[0], clip[2]
1289
+ left = ds.abs_left
1290
+ return left, left + max(getattr(ds, "_edge_lines_width", 0.0),
1291
+ MIN_COLUMN_WIDTH)
1292
+
1293
+
1294
+ def _edge_under_cursor(window, axis, along, across_abs, before=False):
1295
+ """The edge of ``axis`` a right-drag resize should move: the nearest
1296
+ edge strictly AHEAD of the cursor (``along``, window coords on the
1297
+ axis) — or strictly BEHIND it with ``before=True`` — among the views
1298
+ whose band on the other axis contains ``across_abs`` (absolute), i.e.
1299
+ the far edge of the INNERMOST cell under the cursor. Falls back to the
1300
+ window's frame edge on that side (every window registers its pair over
1301
+ the full window) — None only if no edges exist yet."""
1302
+ _ensure_window_state(window)
1303
+ best, best_pos = None, None
1304
+ bands = _bands(window, axis)
1305
+ other = "y" if axis == "x" else "x"
1306
+ origin = window.abs_top if axis == "x" else window.abs_left
1307
+ for key, (ds, edge_list) in _views(window, axis).items():
1308
+ band = bands.get(key)
1309
+ if band is not None:
1310
+ # A keyed layout's own across-axis pair (live edge dicts), not
1311
+ # the host's clip - its siblings share that clip.
1312
+ lo, hi = origin + band[0][other], origin + band[1][other]
1313
+ else:
1314
+ lo, hi = _across_band(ds, axis)
1315
+ if not (lo - 1 <= across_abs <= hi + 1):
1316
+ continue
1317
+ for e in edge_list:
1318
+ pos = e[axis]
1319
+ if before:
1320
+ if pos < along - 0.5 and (best_pos is None or pos > best_pos):
1321
+ best, best_pos = e, pos
1322
+ elif pos > along + 0.5 and (best_pos is None or pos < best_pos):
1323
+ best, best_pos = e, pos
1324
+ fe = _frame(window, axis)
1325
+ if best is None:
1326
+ if fe:
1327
+ best = fe[0] if before else fe[1]
1328
+ elif fe and id(best) in _detached_outer_edges(window, axis):
1329
+ # The innermost cell's far edge is a layout's DETACHED outer edge:
1330
+ # to the hand that cell ends at the edge, so the resize takes
1331
+ # the FRAME edge on that side (the gap cell in _window_graph then
1332
+ # carries the drag back into the layout when the frame expands
1333
+ # it). Latched detached, the drag only ever compressed the layout
1334
+ # and the window never shrank (see chat sidebar, Lukas 09-13).
1335
+ best = fe[0] if before else fe[1]
1336
+ return best
1337
+
1338
+
1339
+ def edge_under_cursor(window, cursor_x_window, cursor_y_abs, left=False):
1340
+ """The column edge a right-drag resize should move, given the drag-start
1341
+ cursor (``cursor_x_window`` in window coords — offset from window.abs_left
1342
+ — and ``cursor_y_abs`` in absolute screen coords).
1343
+
1344
+ Returns the nearest edge dict strictly to the RIGHT of the cursor among
1345
+ the rows whose visible band vertically contains the cursor — i.e. the
1346
+ right edge of the INNERMOST column under the cursor. With ``left=True``
1347
+ (the double-right-drag top-left corner resize) it's the nearest edge
1348
+ strictly to the LEFT instead. Every window registers its own frame edges
1349
+ (``window.id`` entry, full-window span), so a window with NO columns — or
1350
+ a drag in the outermost column — lands on the window's frame edge on
1351
+ that side and the frame resizes exactly as a plain resize.
1352
+ Returns None only if no edges exist yet."""
1353
+ return _edge_under_cursor(window, "x", cursor_x_window, cursor_y_abs,
1354
+ before=left)
1355
+
1356
+
1357
+ def row_edge_under_cursor(window, cursor_y_window, cursor_x_abs, above=False):
1358
+ """edge_under_cursor's row-axis twin: the nearest row edge strictly
1359
+ BELOW the drag-start cursor (``cursor_y_window`` in window coords —
1360
+ offset from window.abs_top; ``cursor_x_abs`` absolute) among the rows
1361
+ views whose band horizontally contains it — the bottom edge of the
1362
+ INNERMOST row under the cursor; ``above=True`` (top-left corner mode)
1363
+ takes the nearest edge strictly ABOVE. A window with no rows — or a
1364
+ drag in the outermost row — lands on the window's top/bottom frame
1365
+ edge, so the frame resizes exactly as a plain resize."""
1366
+ return _edge_under_cursor(window, "y", cursor_y_window, cursor_x_abs,
1367
+ before=above)
1368
+
1369
+
1370
+ def _grab_zone(edges, k, axis="x"):
1371
+ """Grab span for edge k of this view's list along its axis:
1372
+ EDGE_GRAB_WIDTH centered on the line, but split at the midpoint toward
1373
+ each neighbouring edge so adjacent zones never overlap."""
1374
+ pos = edges[k][axis]
1375
+ lo, hi = pos - EDGE_GRAB_WIDTH / 2, pos + EDGE_GRAB_WIDTH / 2
1376
+ if k > 0:
1377
+ lo = max(lo, (edges[k - 1][axis] + pos) / 2)
1378
+ if k < len(edges) - 1:
1379
+ hi = min(hi, (pos + edges[k + 1][axis]) / 2)
1380
+ return lo, hi
1381
+
1382
+
1383
+ def _drag_inc(draw_state, handle, drag, total="total_dx"):
1384
+ """Per-frame pixel delta for a drag handle, from the drag's running
1385
+ total (``total_dx``, or ``total_dy`` for a row edge) against the last
1386
+ seen total. State is PER-HANDLE (dict), never a shared slot: with a
1387
+ shared slot, two events alive in the same frame alternate ownership and
1388
+ each re-fires its full total against a zero baseline — moving edges
1389
+ that were never dragged. Entries are pruned by the caller when their
1390
+ handle has no event, so a new gesture always starts from a clean
1391
+ baseline."""
1392
+ totals = getattr(draw_state, "_drag_totals", None)
1393
+ if totals is None:
1394
+ totals = draw_state._drag_totals = {}
1395
+ last = totals.get(handle, 0.0)
1396
+ now = getattr(drag, total)
1397
+ totals[handle] = now
1398
+ return now - last
1399
+
1400
+
1401
+ class ColumnLayout:
1402
+ """Manual-cell access to the shared edge system, for renderers that draw
1403
+ their own cells instead of routing values through draw_columns (e.g. the
1404
+ code editor's structured|text tabs).
1405
+
1406
+ cols = ColumnLayout(draw_state, n_cols, column_edges=column_edges,
1407
+ column_widths=column_widths)
1408
+ for idx in range(n_cols):
1409
+ with cols.cell(idx) as width:
1410
+ any_renderer(..., width=width)
1411
+ cols.finish()
1412
+
1413
+ Declare ``column_edges=None`` as a named param on the host render_func
1414
+ and pass its resolved value through — ColumnLayout stamps
1415
+ draw_state.column_edges on seed/adoption changes, so auto-state persists
1416
+ the edge dicts exactly like draw_columns. Edge ownership, registration
1417
+ on the root window, drag handles and the collision solve are the same
1418
+ machinery; only the cell CONTENT is the caller's."""
1419
+
1420
+ def __init__(self, draw_state, n_cols, column_edges=None,
1421
+ column_widths=None, left_edge=None, right_edge=None,
1422
+ resizable=True, padding=6.0, border_color=(0.0, 0.0, 0.0, 0.9),
1423
+ padding_y=None, column_mins=None, column_maxes=None,
1424
+ key=None, band=None, persist=True):
1425
+ self.draw_state = draw_state
1426
+ self.n_cols = n_cols
1427
+ n_lines = self.n_lines = n_cols + 1
1428
+ # ``key`` - used when a host builds SEVERAL row layouts on its
1429
+ # draw_state (a tile tree goes flat): each gets its own registry
1430
+ # entry, on_action ids and drag baselines instead of clobbering
1431
+ # the last one's. ``band`` - the layout's own vertical extent as a
1432
+ # pair of ROW edge indices ``(top_edge, bottom_edge)`` (window
1433
+ # coords, read live): the grab zones and edge_under_cursor clip
1434
+ # to it rather than to the host's clip / measured row height.
1435
+ # ``persist=False`` hands the edges list back on ``self.edges``
1436
+ # without stamping ``draw_state.column_edges`` - for a host that
1437
+ # keeps the edges in its own storage (the tile tree).
1438
+ self.key = key
1439
+ self.band = band
1440
+ self.persist = persist
1441
+ # Cells inset by `padding` on every side and the whole row carries
1442
+ # ONE filled rounded band (drawn before the cells, so their rounded
1443
+ # backgrounds read as holes in it): the band shows through the
1444
+ # padding and fills the space between columns, so the row reads as
1445
+ # a single rounded rectangle. border_color=None (or padding 0)
1446
+ # disables it. `padding_y` overrides the VERTICAL inset alone
1447
+ # (default: same as padding) - a bandless host (the code editor's
1448
+ # compare split) keeps the horizontal padding around its dividers
1449
+ # without pushing the cells down below the row origin.
1450
+ # ``column_mins`` - per-column minimum widths (None entries fall
1451
+ # back to MIN_COLUMN_WIDTH), stamped as "min" on each column's
1452
+ # RIGHT edge so the column collision solve, the frame-fit clamp and
1453
+ # the window's min-width floor all honour them (see _edge_min).
1454
+ # ``column_maxes`` - per-column maximum widths (None = unbounded),
1455
+ # stamped as "max" the same way: a column at its cap drags its far
1456
+ # edge along instead of opening further, capped neighbours travel
1457
+ # as one, and a persisted over-cap column is packed back down on
1458
+ # construction (see _edge_max / _drag_edge / _clamp_interior).
1459
+ self.padding = float(padding)
1460
+ self.padding_y = float(padding if padding_y is None else padding_y)
1461
+ self.border_color = border_color
1462
+ self._cell_radius = {}
1463
+
1464
+ window = layout_window(draw_state)
1465
+ _ensure_window_state(window)
1466
+ window_edge_pass(window) # idempotent; usually ran via the wrapper
1467
+ self.window = window
1468
+ self.win_x = window.abs_left
1469
+
1470
+ origin = imgui.get_cursor_screen_pos()
1471
+ self.top = origin[1]
1472
+ self._bottom = self.top
1473
+
1474
+ # Mark this host so descendants' adoption checks can see it.
1475
+ draw_state._column_container = True
1476
+
1477
+ # Far edges belong to the CONTAINER: the enclosing cell when given;
1478
+ # else the WINDOW frame whenever no other columns container sits
1479
+ # above this view (plain wrappers like window bodies / code_file_io
1480
+ # are exceptions - their host IS the window content, so the row IS
1481
+ # the window's row); else own edges (deep-nesting fallback).
1482
+ if left_edge is None and right_edge is None and not _has_columns_ancestor(draw_state):
1483
+ frame_edges = getattr(window, "_frame_edges", None)
1484
+ if frame_edges:
1485
+ left_edge, right_edge = frame_edges
1486
+
1487
+ stored = column_edges if isinstance(column_edges, list) else []
1488
+ ok = (len(stored) == n_lines and
1489
+ all(isinstance(e, dict) and "x" in e for e in stored))
1490
+ seed_valid = True
1491
+ if ok:
1492
+ edges = list(stored)
1493
+ else:
1494
+ if left_edge is not None and right_edge is not None:
1495
+ base, extent = left_edge["x"], right_edge["x"] - left_edge["x"]
1496
+ else:
1497
+ base = origin[0] - self.win_x
1498
+ extent = float(draw_state.content_width or 0)
1499
+ # A seed taken before the container has laid out (content_width
1500
+ # ~0) stays TRANSIENT: render with it this frame, don't persist,
1501
+ # so a later frame re-seeds at the new extent instead of
1502
+ # locking up an all-minimum-width pile.
1503
+ # A specified width counts in full: a seed taken while the
1504
+ # container is too narrow to honour it (an OS window's first
1505
+ # frames at the compositor's default size, before the launch
1506
+ # fit passes) would compress that column to its floor and
1507
+ # persist it so (09-12).
1508
+ spec = list(column_widths or [])[:n_cols]
1509
+ spec += [None] * (n_cols - len(spec))
1510
+ min_total = sum(max(float(w), _column_floor(column_mins, i)) if w is not None
1511
+ else _column_floor(column_mins, i)
1512
+ for i, w in enumerate(spec))
1513
+ seed_valid = extent > min_total
1514
+ extent = max(extent, min_total)
1515
+ edges = _seed_edges(column_widths, n_cols, extent, base=base,
1516
+ column_mins=column_mins,
1517
+ column_maxes=column_maxes)
1518
+ owned = [True] * n_lines
1519
+ if left_edge is not None:
1520
+ edges[0] = left_edge
1521
+ owned[0] = False
1522
+ if right_edge is not None:
1523
+ edges[-1] = right_edge
1524
+ owned[-1] = False
1525
+ # Per-column minimums and maximums ride the edge dicts (re-stamped
1526
+ # every construction, so a hotswapped value applies live; columns
1527
+ # without one keep the flat MIN_COLUMN_WIDTH floor / no cap).
1528
+ for i in range(n_cols):
1529
+ floor = (column_mins[i] if column_mins and i < len(column_mins)
1530
+ else None)
1531
+ if floor:
1532
+ edges[i + 1]["min"] = float(floor)
1533
+ else:
1534
+ edges[i + 1].pop("min", None)
1535
+ cap = _column_cap(column_maxes, i, _column_floor(column_mins, i))
1536
+ if cap is not None:
1537
+ edges[i + 1]["max"] = cap
1538
+ else:
1539
+ edges[i + 1].pop("max", None)
1540
+ # Interior dividers always stay inside the frame - see
1541
+ # _clamp_interior. (Pixel widths otherwise: a frame resize only
1542
+ # affects the divider it touches.)
1543
+ _clamp_interior(edges)
1544
+ if persist and seed_valid and (not ok or any(a is not b for a, b in zip(stored, edges))):
1545
+ # Stamp so auto-state persists the list; in-place x mutations on
1546
+ # the dicts persist without re-stamping.
1547
+ draw_state.column_edges = edges
1548
+ self.edges = edges
1549
+ self.owned = owned
1550
+ # False while the seed was TRANSIENT (container not laid out yet):
1551
+ # a persist=False host should not keep these edges either.
1552
+ self.seed_valid = seed_valid
1553
+
1554
+ # Keyed ("row", id), NOT bare draw_state.id: when the host ds IS the
1555
+ # window (a window body that draws its own cells, e.g. the code
1556
+ # editor's compare split), draw_state.id == window.id and this entry
1557
+ # and window_edge_pass's frame-edge entry (window.id) clobber each
1558
+ # other - the interior edges then never reach _all_edges, so
1559
+ # _solve_collisions doesn't find a dragged edge and silently drops the
1560
+ # drag (the divider reads as "not draggable"). Keys are opaque
1561
+ # (consumers iterate them); the eviction pass matches by ds.
1562
+ registry_key = layout_key("row", draw_state, key)
1563
+ window._edge_views[registry_key] = (draw_state, edges)
1564
+ if band is not None:
1565
+ _bands(window, "x")[registry_key] = band
1566
+ else:
1567
+ _bands(window, "x").pop(registry_key, None)
1568
+ # The cells' floors & caps, owned by THIS view (not _REGISTRY): the
1569
+ # solve reads these, not the far-edge state, so a nested layout
1570
+ # ending on the same edge can't read (or wipe) this row's specs.
1571
+ _specs(window, "x")[registry_key] = (
1572
+ [_column_floor(column_mins, i) for i in range(n_cols)],
1573
+ [_column_cap(column_maxes, i, _column_floor(column_mins, i))
1574
+ for i in range(n_cols)])
1575
+
1576
+ # Grab handles for OWNED edges (foreign far edges already have the
1577
+ # container's handles on the same line).
1578
+ self.active_edge = None
1579
+ # True when the cursor sits in one of this row's column edge grab
1580
+ # zones - i.e. exactly when a left_mouse_drag resize would trigger.
1581
+ # Drives the band highlight below. Recomputed per-frame: while the row
1582
+ # is bounding-hovered core_render re-renders the tile every frame, so
1583
+ # this needs no stored state or invalidation of its own.
1584
+ self.edge_hovered = False
1585
+ if resizable:
1586
+ if band is not None:
1587
+ win_y = window.abs_top
1588
+ grab_top = win_y + band[0]["y"]
1589
+ grab_bottom = win_y + band[1]["y"]
1590
+ else:
1591
+ grab_top = self.top
1592
+ grab_bottom = self.top + max(
1593
+ getattr(draw_state, "_edge_lines_height", 0.0),
1594
+ MIN_ROW_HEIGHT)
1595
+ handle_tag = "" if key is None else f"_{key}"
1596
+ seen_handles = set()
1597
+ for k in range(n_lines):
1598
+ if not owned[k]:
1599
+ continue
1600
+ lo, hi = _grab_zone(edges, k)
1601
+ rect = (self.win_x + lo, grab_top,
1602
+ self.win_x + hi, grab_bottom)
1603
+ if draw_state.hover_eligible(rect=rect):
1604
+ self.edge_hovered = True
1605
+ drag = draw_state.on_action("left_mouse_drag",
1606
+ view_id=f"col_edge{handle_tag}_{k}",
1607
+ rect=rect, priority_delta=1,
1608
+ cursor=mouse_cursor.RESIZE_EW)
1609
+ press = draw_state.on_action("left_mouse_down",
1610
+ view_id=f"col_edge{handle_tag}_{k}",
1611
+ rect=rect, priority_delta=1)
1612
+ if press:
1613
+ # Resize press, before any drag motion: freeze hosts
1614
+ # snap their clean pre-drag capture this frame
1615
+ # (mark_start_offscreen).
1616
+ from meltygui.core.melty import Melty
1617
+ Melty.resize_press_frame = Melty.frame_count
1618
+
1619
+ if not drag:
1620
+ continue
1621
+ self.active_edge = k
1622
+ # Handles: the int edge index for the default layout, a
1623
+ # ("col", key, k) tuple for a keyed one - own namespace
1624
+ # per layout so the prune below never touches a sibling's
1625
+ # baseline.
1626
+ handle = k if key is None else ("col", key, k)
1627
+ seen_handles.add(handle)
1628
+ inc = _drag_inc(draw_state, handle, drag)
1629
+ if inc:
1630
+ # cursor-driven (third slot): a divider pushed past the
1631
+ # pile pushes the frame edge AND, through it, the OS
1632
+ # edge (an app's root) - see _frame_pass's handles
1633
+ window._pending_drags.append(
1634
+ (edges[k], edges[k]["x"] + inc, True))
1635
+
1636
+ totals = getattr(draw_state, "_drag_totals", None)
1637
+ if totals:
1638
+ # Mirror of the window_edge_pass prune scoping: our handles
1639
+ # are the int edge indices (or this key's "col" tuples);
1640
+ # leave the window's "win_*" baselines (and a RowLayout's
1641
+ # "row_*") alone - one dict when the host view IS the window
1642
+ # or hosts both layouts.
1643
+ if key is None:
1644
+ stale = [h for h in totals
1645
+ if isinstance(h, int) and h not in seen_handles]
1646
+ else:
1647
+ stale = [h for h in totals
1648
+ if isinstance(h, tuple) and h[:2] == ("col", key)
1649
+ and h not in seen_handles]
1650
+ for h in stale:
1651
+ del totals[h]
1652
+ if self.active_edge is not None:
1653
+ # size-changing views already update live (see the re-solve
1654
+ # gate in window_edge_pass). freeze_resize hosts skip the
1655
+ # per-frame invalidate too - mid-drag they serve the frozen
1656
+ # tile and update once on release (window_edge_pass).
1657
+ if not draw_state.size_change:
1658
+ if getattr(draw_state, "freeze_resize", False):
1659
+ _defer_freeze_settle(window, draw_state)
1660
+ else:
1661
+ draw_state.invalidate(note=Note(reason="edge drag",
1662
+ **_NOTE))
1663
+ # request_render()
1664
+
1665
+ # The host's VISIBLE box (live clip, screen coords) - the band's
1666
+ # authority for adopted sides, so content margins / the scrollbar
1667
+ # reserve never shave it and the padding reads even all around.
1668
+ self.clip = Core.melty.get_clip_rect() or draw_state.abs_clip_rect
1669
+
1670
+ # ----- the row band: one filled rounded rectangle spanning the
1671
+ # effective bounds, drawn BEFORE the cells so they render on top -
1672
+ # their rounded backgrounds are the holes, the band shows through
1673
+ # the padding and fills the space between columns. ADOPTED far
1674
+ # edges are the container's boundary, so the band takes the
1675
+ # container's visible box (the clip) there - a owned row's band
1676
+ # lands exactly in the panel band's middle. -----
1677
+ if self.padding > 0 and self.border_color is not None:
1678
+ radius = getattr(draw_state, "_band_radius", 6.0) + self.padding
1679
+ # Highlight the band when a divider drag is in progress
1680
+ # (self.active_edge) or the cursor sits over a draggable edge
1681
+ # (self.edge_hovered) - i.e. exactly where a resize drag is firing
1682
+ # or would. Both were computed against the drag grab zones above.
1683
+ band_color = (HIGHLIGHT_TINT
1684
+ if self.edge_hovered or self.active_edge is not None
1685
+ else self.border_color)
1686
+ draw_list = imgui.get_window_draw_list()
1687
+ draw_list.channels_set_current(Core.melty.get_channel() - 1)
1688
+ draw_list.add_rect_filled(
1689
+ snap_int(self.win_x + self._band_left()),
1690
+ snap_int(self.top),
1691
+ snap_int(self.win_x + self._band_right()),
1692
+ snap_int(self._band_bottom()),
1693
+ pack_color(*band_color),
1694
+ rounding=radius)
1695
+ draw_list.channels_set_current(Core.melty.get_channel())
1696
+
1697
+ def width(self, idx):
1698
+ return self.edges[idx + 1]["x"] - self.edges[idx]["x"]
1699
+
1700
+ def _band_left(self):
1701
+ """Band left bound. An ADOPTED far edge sits on the container's
1702
+ boundary, where margins/rounding would shave the band — so the band
1703
+ takes the container's VISIBLE box (the clip) on that side instead.
1704
+ An owned edge is the row's own line and the band spans exactly to
1705
+ it."""
1706
+ if self.owned[0]:
1707
+ return self.edges[0]["x"]
1708
+ if self.clip is not None:
1709
+ return self.clip[0] - self.win_x
1710
+ return self.edges[0]["x"] + self.padding
1711
+
1712
+ def _band_right(self):
1713
+ if self.owned[-1]:
1714
+ return self.edges[-1]["x"]
1715
+ if self.clip is not None:
1716
+ return self.clip[2] - self.win_x
1717
+ return self.edges[-1]["x"] - self.padding
1718
+
1719
+ def _band_bottom(self):
1720
+ """Band bottom (SCREEN y): the host's visible viewport — its clip —
1721
+ not the measured content height, so a short row still frames the
1722
+ whole pane, there's no settle lag, and a cell holding a
1723
+ height-filling child (a Rows) has room to fill. No clip: the
1724
+ measured height."""
1725
+ if self.clip is not None:
1726
+ return self.clip[3]
1727
+ return self.top + max(self.draw_state.height or 0.0, MIN_ROW_HEIGHT)
1728
+
1729
+ def _bound_left(self, idx):
1730
+ return self._band_left() if idx == 0 else self.edges[idx]["x"]
1731
+
1732
+ def _bound_right(self, idx):
1733
+ return (self._band_right() if idx == self.n_cols - 1
1734
+ else self.edges[idx + 1]["x"])
1735
+
1736
+ def inner_width(self, idx):
1737
+ """Content width of column idx: the effective span minus padding."""
1738
+ return max(0.0, self._bound_right(idx) - self._bound_left(idx)
1739
+ - 2 * self.padding)
1740
+
1741
+ def cell_rect(self, idx, height=None):
1742
+ """The screen-space (x, y, w, h) content box `cell(idx, height)`
1743
+ clips to — for a caller decorating a cell from OUTSIDE it, e.g. a
1744
+ card shadow (cast beyond the box, it would be scissored away by
1745
+ the cell's own clip). Without an explicit ``height`` the box runs
1746
+ to the band bottom (the host's visible viewport), the same
1747
+ authority the band paints to."""
1748
+ pad = self.padding
1749
+ pad_y = self.padding_y
1750
+ x0 = snap_int(self.win_x + self._bound_left(idx) + pad)
1751
+ y0 = snap_int(self.top + pad_y)
1752
+ if height is not None:
1753
+ y1 = snap_int(self.top) + snap_int(height) - snap_int(pad_y)
1754
+ else:
1755
+ y1 = snap_int(self._band_bottom()) - snap_int(pad_y)
1756
+ return x0, y0, snap_int(self.inner_width(idx)), y1 - y0
1757
+
1758
+ @contextmanager
1759
+ def cell(self, idx, height=None):
1760
+ """Position the cursor at column idx's content origin (inset by
1761
+ padding from the effective bounds), clip to the content box, and
1762
+ yield the content width. Render the cell inside the with-block.
1763
+ `height` is the FULL cell band including padding — the content box
1764
+ is inset from it."""
1765
+ pad = self.padding
1766
+ pad_y = self.padding_y
1767
+ left_b = self._bound_left(idx)
1768
+ inner_w = self.inner_width(idx)
1769
+ x0, y0, box_w, box_h = self.cell_rect(idx, height)
1770
+ imgui.set_cursor_screen_pos((self.win_x + left_b + pad,
1771
+ self.top + pad_y))
1772
+ Core.melty.push_clip((x0, y0, x0 + box_w, y0 + box_h))
1773
+ # The group makes the cell origin the LINE START of everything
1774
+ # inside: without it only the first item sits at x0 because imgui's
1775
+ # newline returns the cursor to the imgui window's content x, so a
1776
+ # cell stacking multiple rows (eg compare split's inner column) drew
1777
+ # every row after the first left of the cell's clip and lost its
1778
+ # leading pixels (the row chevrons).
1779
+ imgui.begin_group()
1780
+ try:
1781
+ yield inner_w
1782
+ finally:
1783
+ imgui.end_group()
1784
+ Core.melty.pop_clip()
1785
+ bottom = imgui.get_cursor_screen_pos()[1]
1786
+ self._bottom = max(self._bottom, bottom + pad_y)
1787
+
1788
+ def note_child(self, idx, child_ds):
1789
+ """Optional: record column idx's child draw_state so the band's
1790
+ outer rounding can follow the children's actual corner radius
1791
+ (default 6 when never noted)."""
1792
+ if child_ds is not None:
1793
+ radius = getattr(child_ds, "corner_radius", None)
1794
+ if radius is not None:
1795
+ self._cell_radius[idx] = float(radius)
1796
+
1797
+ def finish(self):
1798
+ """Measure the row — the UNIFORM band height the panel draws at next
1799
+ frame — stamp the band rounding from the noted child corners, and
1800
+ leave the flow cursor below the row."""
1801
+ ds = self.draw_state
1802
+ new_h = max(self._bottom - self.top, MIN_ROW_HEIGHT)
1803
+ prev_h = getattr(ds, "_edge_lines_height", None)
1804
+ ds._edge_lines_height = new_h
1805
+ ds._band_radius = (max(self._cell_radius.values())
1806
+ if self._cell_radius else 6.0)
1807
+ if prev_h is None:
1808
+ # FIRST measure only: the band rounding and grab zones drew with
1809
+ # defaults this frame; catch up once. A HEIGHT CHANGE never
1810
+ # invalidates here - the band's pixels come from the row's clip
1811
+ # (or draw_state.height in the no-clip fallback), not from the
1812
+ # measured height, and the measured height only feeds shadow
1813
+ # rects (grab zones, edge_under_cursor bands) that read the fresh
1814
+ # stamp on the next body run without a repaint. The old height-delta
1815
+ # settle force-climbed every ancestor tile on any >1px height
1816
+ # wiggle (typing in an editor cell), recomposing unrelated columns
1817
+ # up the ancestor chain.
1818
+ if not ds.size_change:
1819
+ ds.invalidate(note=Note(reason="row band settle", **_NOTE))
1820
+ request_render()
1821
+ imgui.set_cursor_screen_pos((self.win_x + self.edges[0]["x"],
1822
+ self._bottom))
1823
+ imgui.dummy(0, 0)
1824
+
1825
+
1826
+ class RowLayout:
1827
+ """ColumnLayout's row-axis twin: manual-cell access to the shared edge
1828
+ system for cells stacked top to bottom.
1829
+
1830
+ rows = RowLayout(draw_state, n_rows, row_edges=row_edges,
1831
+ row_heights=row_heights)
1832
+ for idx in range(n_rows):
1833
+ with rows.cell(idx) as height:
1834
+ any_renderer(..., height=height, width=rows.inner_width())
1835
+ rows.finish()
1836
+
1837
+ Declare ``row_edges=None`` as a named param on the host render_func and
1838
+ pass its resolved value through — RowLayout stamps draw_state.row_edges
1839
+ on seed/adoption changes, so auto-state persists the ``{"y": …}`` edge
1840
+ dicts exactly like draw_rows. Edges register on the root window's
1841
+ ``_row_views`` and solve in the y-axis flat pass; the window's top and
1842
+ bottom frame edges are the far edges of a window-direct rows view, so a
1843
+ top/bottom frame drag squeezes the first/last row and an interior edge
1844
+ pushed into the frame slides or grows the window — the same physics
1845
+ columns run along x. The orthogonal extent is simpler than a column's:
1846
+ a row's WIDTH is pinned by the host's visible box, so no measure step
1847
+ is needed to size the cells."""
1848
+
1849
+ def __init__(self, draw_state, n_rows, row_edges=None, row_heights=None,
1850
+ top_edge=None, bottom_edge=None, resizable=True,
1851
+ padding=6.0, border_color=(0.0, 0.0, 0.0, 0.9),
1852
+ padding_x=None, row_mins=None, row_maxes=None,
1853
+ key=None, band=None, persist=True):
1854
+ self.draw_state = draw_state
1855
+ self.n_rows = n_rows
1856
+ n_lines = self.n_lines = n_rows + 1
1857
+ # ``key`` / ``band`` / ``persist`` - as on ColumnLayout; ``band``
1858
+ # here is the layout's horizontal extent, a pair of COLUMN edge
1859
+ # dicts ``(left_edge, right_edge)``.
1860
+ self.key = key
1861
+ self.band = band
1862
+ self.persist = persist
1863
+ # Same band + padding contract as ColumnLayout; ``padding_x``
1864
+ # overrides the HORIZONTAL inset alone. ``row_mins`` / ``row_maxes``
1865
+ # stamp "min" / "max" on each row's BOTTOM edge (see _edge_min /
1866
+ # _edge_max).
1867
+ self.padding = float(padding)
1868
+ self.padding_x = float(padding if padding_x is None else padding_x)
1869
+ self.border_color = border_color
1870
+ self._cell_radius = {}
1871
+
1872
+ window = layout_window(draw_state)
1873
+ _ensure_window_state(window)
1874
+ window_edge_pass(window) # idempotent; usually ran via the wrapper
1875
+ self.window = window
1876
+ self.win_x, self.win_y = window.abs_left, window.abs_top
1877
+
1878
+ # The host's VISIBLE box (or clip, screen coords): the rows's full
1879
+ # horizontal extent, and the authority of the adopted top/bottom.
1880
+ self.clip = Core.melty.get_clip_rect() or draw_state.abs_clip_rect
1881
+ origin = imgui.get_cursor_screen_pos()
1882
+ # Where the flow cursor stood: an adopted top edge (the window frame
1883
+ # top sits at the header's top) never starts the first row above
1884
+ # this.
1885
+ self.origin_y = origin[1]
1886
+ if self.clip is not None:
1887
+ self.left, self.right = self.clip[0], self.clip[2]
1888
+ else:
1889
+ self.left = origin[0]
1890
+ self.right = origin[0] + float(draw_state.content_width or 0)
1891
+ self._right = self.left
1892
+
1893
+ # Mark the host so descendants' adoption climbs can ignore it.
1894
+ draw_state._row_container = True
1895
+
1896
+ # Far edges belong to the CONTAINER: the enclosing cell when given;
1897
+ # else the WINDOW's top/bottom frame when no other rows
1898
+ # container sits above this view; else own edges (fallback).
1899
+ if top_edge is None and bottom_edge is None and not _has_rows_ancestor(draw_state):
1900
+ frame_rows = _frame(window, "y")
1901
+ if frame_rows:
1902
+ top_edge, bottom_edge = frame_rows
1903
+
1904
+ stored = row_edges if isinstance(row_edges, list) else []
1905
+ ok = (len(stored) == n_lines and
1906
+ all(isinstance(e, dict) and "y" in e for e in stored))
1907
+ seed_valid = True
1908
+ if ok:
1909
+ edges = list(stored)
1910
+ else:
1911
+ if top_edge is not None and bottom_edge is not None:
1912
+ # Share over the VISIBLE span: the adopted top may sit above
1913
+ # the flow origin (window frame top → header top), and the
1914
+ # shares only split the room the rows actually get.
1915
+ base = max(top_edge["y"], self.origin_y - self.win_y)
1916
+ extent = bottom_edge["y"] - base
1917
+ else:
1918
+ base = self.origin_y - self.win_y
1919
+ extent = float(draw_state.height or 0)
1920
+ min_total = sum(_row_floor(row_mins, i) for i in range(n_rows))
1921
+ seed_valid = extent > min_total
1922
+ extent = max(extent, min_total)
1923
+ edges = _seed_edges(row_heights, n_rows, extent, base=base,
1924
+ column_mins=row_mins, column_maxes=row_maxes,
1925
+ axis="y")
1926
+ owned = [True] * n_lines
1927
+ if top_edge is not None:
1928
+ edges[0] = top_edge
1929
+ owned[0] = False
1930
+ if bottom_edge is not None:
1931
+ edges[-1] = bottom_edge
1932
+ owned[-1] = False
1933
+ self.edges = edges
1934
+ self.owned = owned
1935
+ self.seed_valid = seed_valid
1936
+ for i in range(n_rows):
1937
+ floor = row_mins[i] if row_mins and i < len(row_mins) else None
1938
+ if floor:
1939
+ edges[i + 1]["min"] = float(floor)
1940
+ else:
1941
+ edges[i + 1].pop("min", None)
1942
+ cap = _column_cap(row_maxes, i, _row_floor(row_mins, i))
1943
+ if cap is not None:
1944
+ edges[i + 1]["max"] = cap
1945
+ else:
1946
+ edges[i + 1].pop("max", None)
1947
+ # The LEAD: an adopted top edge can sit well above where the first
1948
+ # row begins (window frame top → the header and anything drawn
1949
+ # before the rows). The flat solve measures the first row from the
1950
+ # edge, so its floor is raised by that lead - the VISIBLE first row
1951
+ # keeps its minimum when the window frame edge is dragged, and the
1952
+ # frame-fit clamp places it correctly.
1953
+ lead = self._band_top() - edges[0]["y"]
1954
+ if lead > 0.5:
1955
+ edges[1]["min"] = _row_floor(row_mins, 0) + lead
1956
+ _clamp_interior(edges, axis="y")
1957
+ if persist and seed_valid and (not ok or any(a is not b for a, b in zip(stored, edges))):
1958
+ draw_state.row_edges = edges
1959
+
1960
+ # Keyed ("rows", id) - see ColumnLayout's ("row", id) note: a host
1961
+ # ds that IS the window must not clobber the spec entry.
1962
+ registry_key = layout_key("rows", draw_state, key)
1963
+ _views(window, "y")[registry_key] = (draw_state, edges)
1964
+ if band is not None:
1965
+ _bands(window, "y")[registry_key] = band
1966
+ else:
1967
+ _bands(window, "y").pop(registry_key, None)
1968
+ floors = [_row_floor(row_mins, i) for i in range(n_rows)]
1969
+ if lead > 0.5:
1970
+ floors[0] += lead
1971
+ _specs(window, "y")[registry_key] = (
1972
+ floors,
1973
+ [_column_cap(row_maxes, i, _row_floor(row_mins, i))
1974
+ for i in range(n_rows)])
1975
+
1976
+ self.active_edge = None
1977
+ self.edge_hovered = False
1978
+ if resizable:
1979
+ if band is not None:
1980
+ grab_left = self.win_x + band[0]["x"]
1981
+ grab_right = self.win_x + band[1]["x"]
1982
+ else:
1983
+ grab_left, grab_right = self.left, self.right
1984
+ handle_tag = "" if key is None else f"_{key}"
1985
+ seen_handles = set()
1986
+ for k in range(n_lines):
1987
+ if not owned[k]:
1988
+ continue
1989
+ lo, hi = _grab_zone(edges, k, axis="y")
1990
+ rect = (grab_left, self.win_y + lo, grab_right, self.win_y + hi)
1991
+ if draw_state.hover_eligible(rect=rect):
1992
+ self.edge_hovered = True
1993
+ drag = draw_state.on_action("left_mouse_drag",
1994
+ view_id=f"row_edge{handle_tag}_{k}",
1995
+ rect=rect, priority_delta=1,
1996
+ cursor=mouse_cursor.RESIZE_NS)
1997
+ press = draw_state.on_action("left_mouse_down",
1998
+ view_id=f"row_edge{handle_tag}_{k}",
1999
+ rect=rect, priority_delta=1)
2000
+ if press:
2001
+ from meltygui.core.melty import Melty
2002
+ Melty.resize_press_frame = Melty.frame_count
2003
+ if not drag:
2004
+ continue
2005
+ self.active_edge = k
2006
+ handle = f"row_{k}" if key is None else ("rows", key, k)
2007
+ seen_handles.add(handle)
2008
+ inc = _drag_inc(draw_state, handle, drag, total="total_dy")
2009
+ if inc:
2010
+ _pending(window, "y").append(
2011
+ (edges[k], edges[k]["y"] + inc, True)) # cursor-driven, as for columns
2012
+
2013
+ totals = getattr(draw_state, "_drag_totals", None)
2014
+ if totals:
2015
+ # Own namespace only ("row_*", or this key's "rows"
2016
+ # prefix): a ColumnLayout on the same host keeps its int
2017
+ # keys, the window its "win_*".
2018
+ if key is None:
2019
+ stale = [h for h in totals
2020
+ if isinstance(h, str) and h.startswith("row_")
2021
+ and h not in seen_handles]
2022
+ else:
2023
+ stale = [h for h in totals
2024
+ if isinstance(h, tuple) and h[:2] == ("rows", key)
2025
+ and h not in seen_handles]
2026
+ for h in stale:
2027
+ del totals[h]
2028
+ if self.active_edge is not None:
2029
+ if not draw_state.size_change:
2030
+ if getattr(draw_state, "freeze_resize", False):
2031
+ _defer_freeze_settle(window, draw_state)
2032
+ else:
2033
+ draw_state.invalidate(note=Note(reason="edge drag",
2034
+ **_NOTE))
2035
+
2036
+ # ----- the column band: a filled rounded rectangle, the cells'
2037
+ # rounded backgrounds its holes (see ColumnLayout) -----
2038
+ if self.padding > 0 and self.border_color is not None:
2039
+ radius = getattr(draw_state, "_band_radius", 6.0) + self.padding
2040
+ band_color = (HIGHLIGHT_TINT
2041
+ if self.edge_hovered or self.active_edge is not None
2042
+ else self.border_color)
2043
+ draw_list = imgui.get_window_draw_list()
2044
+ draw_list.channels_set_current(Core.melty.get_channel() - 1)
2045
+ draw_list.add_rect_filled(
2046
+ snap_int(self.left),
2047
+ snap_int(self.win_y + self._band_top()),
2048
+ snap_int(self.right),
2049
+ snap_int(self.win_y + self._band_bottom()),
2050
+ pack_color(*band_color),
2051
+ rounding=radius)
2052
+ draw_list.channels_set_current(Core.melty.get_channel())
2053
+
2054
+ def height(self, idx):
2055
+ return self.edges[idx + 1]["y"] - self.edges[idx]["y"]
2056
+
2057
+ def _band_top(self):
2058
+ """Band top bound (window coords). An owned edge is the row's own
2059
+ line; an ADOPTED one is the container's boundary, so the band
2060
+ starts at the flow origin — never above the container's visible
2061
+ box."""
2062
+ if self.owned[0]:
2063
+ return self.edges[0]["y"]
2064
+ top = self.origin_y - self.win_y
2065
+ if self.clip is not None:
2066
+ top = max(top, self.clip[1] - self.win_y)
2067
+ return top
2068
+
2069
+ def _band_bottom(self):
2070
+ """Band bottom bound (window coords): the adopted edge's line, but
2071
+ never past the container's visible box (a window-direct view ends
2072
+ at the window's content clip, a cell-nested one at its cell)."""
2073
+ if self.owned[-1]:
2074
+ return self.edges[-1]["y"]
2075
+ bottom = self.edges[-1]["y"]
2076
+ if self.clip is not None:
2077
+ bottom = min(bottom, self.clip[3] - self.win_y)
2078
+ else:
2079
+ bottom -= self.padding
2080
+ return bottom
2081
+
2082
+ def _bound_top(self, idx):
2083
+ return self._band_top() if idx == 0 else self.edges[idx]["y"]
2084
+
2085
+ def _bound_bottom(self, idx):
2086
+ return (self._band_bottom() if idx == self.n_rows - 1
2087
+ else self.edges[idx + 1]["y"])
2088
+
2089
+ def inner_height(self, idx):
2090
+ """Content height of row idx: the effective span minus padding."""
2091
+ return max(0.0, self._bound_bottom(idx) - self._bound_top(idx)
2092
+ - 2 * self.padding)
2093
+
2094
+ def inner_width(self):
2095
+ """Content width every row gets: the visible box minus the
2096
+ horizontal padding."""
2097
+ return max(0.0, self.right - self.left - 2 * self.padding_x)
2098
+
2099
+ def cell_rect(self, idx, width=None):
2100
+ """The screen-space (x, y, w, h) content box `cell(idx, width)`
2101
+ clips to. ``width`` is the FULL cell band including padding; by
2102
+ default the box runs to the visible box's right."""
2103
+ pad = self.padding
2104
+ pad_x = self.padding_x
2105
+ x0 = snap_int(self.left + pad_x)
2106
+ y0 = snap_int(self.win_y + self._bound_top(idx) + pad)
2107
+ if width is not None:
2108
+ x1 = snap_int(self.left) + snap_int(width) - snap_int(pad_x)
2109
+ else:
2110
+ x1 = snap_int(self.right) - snap_int(pad_x)
2111
+ y1 = snap_int(self.win_y + self._bound_bottom(idx)) - snap_int(pad)
2112
+ return x0, y0, x1 - x0, y1 - y0
2113
+
2114
+ @contextmanager
2115
+ def cell(self, idx, width=None):
2116
+ """Position the cursor at row idx's content origin, clip to the
2117
+ content box, and yield the content HEIGHT (pass it as ``height=``
2118
+ to the child, with ``width=inner_width()`` — a passed height pins
2119
+ the child fixed-size, so its width must be pinned too)."""
2120
+ x0, y0, box_w, box_h = self.cell_rect(idx, width)
2121
+ imgui.set_cursor_screen_pos((x0, y0))
2122
+ Core.melty.push_clip((x0, y0, x0 + box_w, y0 + box_h))
2123
+ imgui.begin_group()
2124
+ try:
2125
+ yield self.inner_height(idx)
2126
+ finally:
2127
+ imgui.end_group()
2128
+ Core.melty.pop_clip()
2129
+ right = imgui.get_item_rect_max()[0]
2130
+ self._right = max(self._right, right + self.padding_x)
2131
+
2132
+ def note_child(self, idx, child_ds):
2133
+ if child_ds is not None:
2134
+ radius = getattr(child_ds, "corner_radius", None)
2135
+ if radius is not None:
2136
+ self._cell_radius[idx] = float(radius)
2137
+
2138
+ def finish(self):
2139
+ """Stamp the measured band width (edge_under_cursor's fallback
2140
+ band) and the band rounding, and leave the flow cursor below the
2141
+ last row."""
2142
+ ds = self.draw_state
2143
+ prev_w = getattr(ds, "_edge_lines_width", None)
2144
+ ds._edge_lines_width = max(self._right - self.left, MIN_COLUMN_WIDTH)
2145
+ ds._band_radius = (max(self._cell_radius.values())
2146
+ if self._cell_radius else 6.0)
2147
+ if prev_w is None:
2148
+ if not ds.size_change:
2149
+ ds.invalidate(note=Note(reason="row band settle", **_NOTE))
2150
+ request_render()
2151
+ imgui.set_cursor_screen_pos((self.left,
2152
+ self.win_y + self._band_bottom()))
2153
+ imgui.dummy(0, 0)