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,1075 @@
1
+ import collections
2
+ import time
3
+ import weakref
4
+ from enum import Enum
5
+
6
+ import meltygui_imgui as imgui
7
+
8
+ from meltygui.state.model_enums import RelaxedEnum
9
+ from meltygui.core.rendering.modes import Modes
10
+ from meltygui.core.rendering.render_funcs import RenderFuncs
11
+ from meltygui.core.windowing.glfw_utils import request_render
12
+ from meltygui.core.rendering.core_decoration import Core
13
+ from meltygui.core.rendering.window_decoration import window
14
+ from meltygui.view.header_view import draw_header
15
+
16
+
17
+ class Change:
18
+ """One recorded edit (or coalesced burst): a draw_state's value moving from
19
+ `old` to `new`. `ui` is a snapshot of the draw_state's transient UI state
20
+ (caret/selection/scroll) captured before the edit, restored alongside `old`
21
+ on undo. While a burst keeps coalescing, `new`/`t`/`ui_after` advance but
22
+ `old`/`ui`/`direction` stay pinned to the group's start, so one undo reverts
23
+ the whole burst and drops the caret where it began. `ui_after` is the caret
24
+ snapshot at the group's current end, used by redo so re-applying lands the
25
+ caret after the redone text. `t` is the wall-clock time of the last edit
26
+ folded in; `direction` is 'insert'/'delete'/'replace'/None (None for non-text
27
+ values). Typing runs (UndoManager._can_coalesce) also track `edit_end` —
28
+ the buffer offset where the run currently ends, the caret edge the next
29
+ keystroke must touch to fold in — and `edge_char`, the last character the
30
+ run inserted or removed in typing order (the word-step test reads it).
31
+ `sealed` closes a change to further folding: a standalone edit (paste,
32
+ Enter, a replaced selection) is born sealed, and UndoStack.seal_top seals
33
+ whatever sits on top after an undo/redo."""
34
+
35
+ def __init__(self, draw_state, old, new, ui=None, t=0.0, direction=None, ui_after=None,
36
+ group_id=0, frame=0, edit_end=None, edge_char="", sealed=False):
37
+ self.draw_state = draw_state
38
+ self.old = old
39
+ self.new = new
40
+ self.ui = ui
41
+ self.ui_after = ui_after
42
+ self.t = t
43
+ self.direction = direction
44
+ self.edit_end = edit_end
45
+ self.edge_char = edge_char
46
+ self.sealed = sealed
47
+ # Undo group: changes from one user action (e.g. multiple views reacting to
48
+ # the same edit, recorded within a frame or two) share a group_id and are
49
+ # undone/redone together. `frame` is the frame_count the change last
50
+ # touched, used to decide group membership.
51
+ self.group_id = group_id
52
+ self.frame = frame
53
+
54
+ @property
55
+ def display_name(self):
56
+ return getattr(self.draw_state, "name", None) or "?"
57
+
58
+ def apply(self, undo):
59
+ """Re-apply one side of this change (undo → `old`, redo → `new`).
60
+ Subclasses override this — it's the ONLY kind-specific hook, so
61
+ UndoStack never inspects change types."""
62
+ if undo:
63
+ UndoManager._request(self.draw_state, self.old, self.ui)
64
+ else:
65
+ UndoManager._request(self.draw_state, self.new, self.ui_after)
66
+ #
67
+ # def __repr__(self):
68
+ # name = getattr(self.draw_state, "name", "?")
69
+ # return f"Change({name}: {self.old!r} -> {self.new!r})"
70
+
71
+ class SetterChange(Change):
72
+ """A value change whose owner has no wrapper of its own to land an undo
73
+ through — draw_tuple_fast's colour chips (an immediate-mode chip in a
74
+ host body: several share the host's draw_state, told apart by `key`,
75
+ the chip's view_id). Replay calls `setter(value)`, the write the caller
76
+ would have made, instead of routing through Melty.undo_requests."""
77
+
78
+ def __init__(self, draw_state, old, new, setter, key=None, label=None, **kw):
79
+ super().__init__(draw_state, old, new, **kw)
80
+ self.setter = setter
81
+ self.key = key
82
+ self.label = label
83
+
84
+ @property
85
+ def display_name(self):
86
+ if self.label:
87
+ return self.label
88
+ base = getattr(self.draw_state, "name", None) or "?"
89
+ return f"{base}/{self.key}" if self.key else base
90
+
91
+ def apply(self, undo):
92
+ try:
93
+ self.setter(self.old if undo else self.new)
94
+ finally:
95
+ from meltygui.core.windowing.glfw_utils import request_render
96
+ cache = getattr(Core.melty, "cache", None)
97
+ if cache is not None and getattr(self.draw_state, "_tile_id", None) is not None:
98
+ cache.invalidate_up(self.draw_state._tile_id, force=True)
99
+ request_render()
100
+
101
+
102
+ class UndoStack:
103
+ """One independent undo/redo timeline. The edit stack (UndoManager.stack)
104
+ and the navigation stack (NavUndo.stack) are instances; adding another
105
+ timeline is: make an UndoStack, push Change subclasses into it, and wire
106
+ something to its undo()/redo(). Changes re-apply THEMSELVES (Change.apply)
107
+ — a stack never inspects change kinds. Grouping (several changes from one
108
+ user action undoing as a unit) rides group_id, same as before the split."""
109
+
110
+ def __init__(self, name, maxlen=128):
111
+ self.name = name
112
+ self.history = collections.deque(maxlen=maxlen)
113
+ # undo() moves a popped group here; redo() moves it back. Any fresh
114
+ # push clears this - you can't redo after diverging.
115
+ self.redo_stack = collections.deque(maxlen=maxlen)
116
+ self._next_group_id = 0
117
+
118
+ def new_group_id(self):
119
+ self._next_group_id += 1
120
+ return self._next_group_id
121
+
122
+ def push(self, change):
123
+ self.redo_stack.clear()
124
+ self.history.append(change)
125
+
126
+ def can_undo(self):
127
+ return bool(self.history)
128
+
129
+ def can_redo(self):
130
+ return bool(self.redo_stack)
131
+
132
+ def _pop_group(self):
133
+ """Pop the newest group (all trailing changes sharing the top group_id)
134
+ off `history`, newest-first."""
135
+ if not self.history:
136
+ return []
137
+ gid = self.history[-1].group_id
138
+ group = []
139
+ while self.history and self.history[-1].group_id == gid:
140
+ group.append(self.history.pop())
141
+ return group
142
+
143
+ def seal_top(self):
144
+ """Close the newest group to further folding. Called after every
145
+ undo/redo: the next keystroke is a fresh step, never a continuation
146
+ of the step the replay just exposed or restored (typing right after
147
+ an undo must not grow the older word — IntelliJ flushes its command
148
+ merger the same way)."""
149
+ if not self.history:
150
+ return
151
+ gid = self.history[-1].group_id
152
+ for change in reversed(self.history):
153
+ if change.group_id != gid:
154
+ break
155
+ change.sealed = True
156
+
157
+ def undo(self):
158
+ group = self._pop_group()
159
+ if not group:
160
+ return
161
+ self.redo_stack.append(group)
162
+ for change in group:
163
+ change.apply(undo=True)
164
+ self.seal_top()
165
+
166
+ def redo(self):
167
+ if not self.redo_stack:
168
+ return
169
+ group = self.redo_stack.pop()
170
+ for change in reversed(group): # restore original append order
171
+ self.history.append(change)
172
+ # Replay in the order the changes were recorded (undo walked them
173
+ # newest-first). Two changes in one group that affect the same target
174
+ # (a tab switch and the caret placed by it) must end where the LATER one
175
+ # left it, so the last applied is the newest.
176
+ for change in reversed(group):
177
+ change.apply(undo=False)
178
+ self.seal_top()
179
+
180
+
181
+ class NavChange(Change):
182
+ """A file-navigation step (editor tab switch / jump-to). `old`/`new` are
183
+ (path, line, instance) locations — not values — and there is no
184
+ draw_state: replay navigates (open_in_editor / tab select) instead of
185
+ writing a value back through the wrapper. line None means "wherever that
186
+ file's editor last left its caret" (each file's draw_text keeps its own
187
+ caret/scroll on its persistent draw_state); instance is the code-editor
188
+ window the step happened in, so replay lands in the same window."""
189
+
190
+ def __init__(self, old_loc, new_loc, t=0.0, group_id=0, frame=0):
191
+ super().__init__(None, old_loc, new_loc, t=t, group_id=group_id,
192
+ frame=frame)
193
+
194
+ @property
195
+ def display_name(self):
196
+ return "goto"
197
+
198
+ def apply(self, undo):
199
+ NavUndo._apply_location(self.old if undo else self.new)
200
+
201
+ def file_location(self, undo):
202
+ """The (path, line, instance) this side lands in (dock tooltips /
203
+ tints), or None."""
204
+ return self.old if undo else self.new
205
+
206
+
207
+ class CaretLocation:
208
+ """Where the text caret sits: a focused draw_text and its caret /
209
+ selection offsets. `tile_id` is the identity used for equality and for
210
+ resolving the LIVE draw_state on replay (a rebuilt tile hands out a fresh
211
+ draw_state object for the same tile — the weakref is only the fallback).
212
+ `path`/`instance` are set when the view is a code-editor pane (the tab
213
+ to select and the editor window to raise on replay); `line` is the
214
+ FULL-buffer caret line for coalescing and the dock's target tooltip."""
215
+
216
+ __slots__ = ("draw_state_ref", "tile_id", "cursor", "selection_start",
217
+ "selection_end", "path", "instance", "line")
218
+
219
+ def __init__(self, draw_state, cursor, selection_start, selection_end,
220
+ path=None, instance=0, line=None):
221
+ self.draw_state_ref = weakref.ref(draw_state)
222
+ self.tile_id = draw_state._tile_id
223
+ self.cursor = cursor
224
+ self.selection_start = selection_start
225
+ self.selection_end = selection_end
226
+ self.path = path
227
+ self.instance = instance
228
+ self.line = line
229
+
230
+ @property
231
+ def key(self):
232
+ return (self.tile_id, self.cursor, self.selection_start,
233
+ self.selection_end)
234
+
235
+ def __eq__(self, other):
236
+ return isinstance(other, CaretLocation) and self.key == other.key
237
+
238
+ def __hash__(self):
239
+ return hash(self.key)
240
+
241
+ def draw_state(self):
242
+ """The live draw_state for this tile — the cache's current object
243
+ first (rebuilt tiles), the recorded one as fallback, None when the
244
+ view is gone."""
245
+ cache = getattr(Core.melty, "cache", None)
246
+ live = None
247
+ if cache is not None and self.tile_id is not None:
248
+ live = cache.key_to_draw_state.get(self.tile_id)
249
+ return live if live is not None else self.draw_state_ref()
250
+
251
+ def __repr__(self):
252
+ if self.path:
253
+ where = self.path.rsplit("/", 1)[-1]
254
+ return f"{where}:{self.line + 1}" if self.line is not None else where
255
+ draw_state = self.draw_state_ref()
256
+ name = str(getattr(draw_state, "name", "?") or "?").split("##")[0]
257
+ return f"{name}@{self.cursor}"
258
+
259
+
260
+ class CaretChange(Change):
261
+ """A caret / text-focus step: `old`/`new` are CaretLocations — the
262
+ focused draw_text and where its caret sat. Consecutive small moves in the
263
+ same view fold into one change (NavUndo.record_caret), so an arrow-key
264
+ walk is one step back. No draw_state on the change itself: replay resolves
265
+ the LIVE view through the location (NavUndo._apply_caret) and writes the
266
+ caret + focus directly, summoning the view's tab / window first."""
267
+
268
+ def __init__(self, old_loc, new_loc, t=0.0, group_id=0, frame=0):
269
+ super().__init__(None, old_loc, new_loc, t=t, group_id=group_id,
270
+ frame=frame)
271
+
272
+ @property
273
+ def display_name(self):
274
+ return "caret"
275
+
276
+ def apply(self, undo):
277
+ NavUndo._apply_caret(self.old if undo else self.new)
278
+
279
+ def file_location(self, undo):
280
+ location = self.old if undo else self.new
281
+ if location is None or not location.path:
282
+ return None
283
+ return (location.path,
284
+ None if location.line is None else location.line + 1,
285
+ location.instance)
286
+
287
+
288
+ class WindowChange(Change):
289
+ """A window open/close step. `draw_state` is the WINDOW's draw_state;
290
+ `old`/`new` are the `closed` flag before/after the user's toggle. Replay
291
+ just writes the flag back (raising the window when it reopens)."""
292
+
293
+ def __init__(self, window_ds, closed_before, closed_after, t=0.0,
294
+ group_id=0, frame=0):
295
+ super().__init__(window_ds, closed_before, closed_after, t=t,
296
+ group_id=group_id, frame=frame)
297
+
298
+ @property
299
+ def display_name(self):
300
+ base = str(getattr(self.draw_state, "name", "?")).split("##")[0]
301
+ return f"{'close' if self.new else 'open'} {base}"
302
+
303
+ def apply(self, undo):
304
+ wds = self.draw_state
305
+ wds.closed = self.old if undo else self.new
306
+ if not wds.closed:
307
+ Core.melty.move_window_to_front(wds)
308
+ # Repaint the window's own subtree (a reopen must redraw its content,
309
+ # a close must clear its blit from the compositor) ...
310
+ if Core.melty.cache is not None and wds._tile_id is not None:
311
+ Core.melty.cache.invalidate_up(wds._tile_id, force=True, max_depth=4)
312
+ # ... and the dock/window list rows, same frame for interactive paths.
313
+ Core.melty.cache.invalidate_up_by_obj(Core.melty.registered_windows)
314
+ request_render()
315
+
316
+
317
+ class WindowMoveChange(Change):
318
+ """A window move step: `draw_state` is the WINDOW's draw_state, `old`/`new`
319
+ its window_pos before/after one hand drag (recorded at gesture END by
320
+ core_render's window_move block, so a whole drag is one step). Replay
321
+ writes the position back — window_pos is parent-relative for nested
322
+ windows, and the recorded value is in that same space. A window closed
323
+ since the move still takes the write (invisible until reopened); replay
324
+ never reopens or raises for a move alone."""
325
+
326
+ def __init__(self, window_ds, old_pos, new_pos, t=0.0, group_id=0,
327
+ frame=0):
328
+ super().__init__(window_ds, (old_pos[0], old_pos[1]),
329
+ (new_pos[0], new_pos[1]), t=t, group_id=group_id,
330
+ frame=frame)
331
+
332
+ @property
333
+ def display_name(self):
334
+ base = str(getattr(self.draw_state, "name", "?")).split("##")[0]
335
+ return f"move {base}"
336
+
337
+ def apply(self, undo):
338
+ wds = self.draw_state
339
+ wds.window_pos = self.old if undo else self.new
340
+ if Core.melty.cache is not None and wds._tile_id is not None:
341
+ Core.melty.cache.invalidate_up(wds._tile_id, force=True, max_depth=4)
342
+ request_render()
343
+
344
+
345
+
346
+
347
+ @window(view_func=RenderFuncs.draw_undo_manager, live=True)
348
+ class UndoManager:
349
+ # draw_state -> ordered list of Changes recorded for that node. Safe to key
350
+ # on the draw_state object: DrawState uses identity equality and hashes on
351
+ # its unique id, so distinct nodes never collide as keys.
352
+ change_history = {}
353
+ MAX_HISTORY = 128
354
+
355
+ # Only record a change when both old and new are one of these immutable
356
+ # primitives. Snapshotting a mutable object by reference is unsound - it
357
+ # could be aliased and mutated after the fact, so undo would restore the
358
+ # wrong value. Start with types that are safe to keep by reference; widen
359
+ # as snapshotting for richer types is implemented.
360
+ APPROVED_TYPES = (float, int, str, bool, tuple, Enum, RelaxedEnum)
361
+
362
+ # The EDIT timeline (value changes). Navigation lives on its own stack -
363
+ # NavUndo.stack - so Ctrl+Z never yanks the viewport and Ctrl+Shift+arrows
364
+ # never deletes text. `history`/`redo_stack` alias the stack's deques (same
365
+ # objects) for the render func and older call sites.
366
+ stack = UndoStack("edits", maxlen=MAX_HISTORY)
367
+ history = stack.history
368
+ redo_stack = stack.redo_stack
369
+
370
+ # Text coalescing mirrors IntelliJ's undo merge (see _can_coalesce, knobs
371
+ # in Tweak.CodeEditor.max_word_wrap and undo_typing_max_chars). Each
372
+ # keystroke folds into the previous step only while the value stays
373
+ # contiguous in VALUE (the step's `new` is this edit's `old` - an undo or
374
+ # an external write breaks it) and in POSITION (the edit lands on the
375
+ # run's caret edge - typing somewhere else breaks it), keeps its
376
+ # insert/delete direction, and doesn't start a new word (a non-space
377
+ # right after whitespace; backspace runs mirror it). There is deliberately
378
+ # NO pause timeout for text - a step stores what was typed in one place,
379
+ # however slowly - but nothing folds across an undo/redo (UndoStack.seal_top).
380
+ # Edits that aren't keystroke-sized (a newline, more than
381
+ # undo_typing_max_chars characters, a replaced selection) are standalone
382
+ # steps: paste, Enter + auto-indent, Tab, completions, comment/un etc.
383
+ # Non-text values (floats/ints) keep the timer: a held widget streams a
384
+ # value per frame, and COALESCE_WINDOW + _imgui_is_active make the drag
385
+ # one undo.
386
+ COALESCE_WINDOW = 0.6
387
+
388
+ # Cross-view grouping: one user action can make several different views record
389
+ # a change in the same frame (or a frame or two apart, when a derived view
390
+ # updates a tick later). Changes whose frames are within GROUP_FRAME_WINDOW of
391
+ # the previous change join the same group and undo/redo as a unit, so the user
392
+ # doesn't see edits alternating back and forth between views. (Same-draw_state
393
+ # bursts still fold in _can_coalesce regardless of frame distance.)
394
+ GROUP_FRAME_WINDOW = 2
395
+
396
+ settle_for = 2 # 2 frame at start
397
+
398
+ @classmethod
399
+ def _request(cls, ds, value, ui):
400
+ # Register a (value, ui) request with Melty for `ds`. next_render
401
+ # intercepts that draw_state's return next frame and reports (True, value)
402
+ # with the caret/selection restored. Its parent then writes `value` back
403
+ # into the model, exactly as if the user had typed it.
404
+ Core.melty.undo_requests[ds] = (value, ui)
405
+ # Force the target and its parent wrapper to re-render this frame so the
406
+ # restored data actually propagates: a blitted parent would otherwise never
407
+ # call the child wrapper that performs the interception.
408
+ cache = getattr(Core.melty, "cache", None)
409
+ if cache is not None:
410
+ cache.invalidate_up(ds._tile_id, force=True)
411
+ if ds._parent is not None:
412
+ cache.invalidate_up(ds._parent._tile_id, force=True)
413
+ request_render()
414
+
415
+ @classmethod
416
+ def undo(cls):
417
+ cls.stack.undo()
418
+
419
+ @classmethod
420
+ def redo(cls):
421
+ cls.stack.redo()
422
+
423
+ @classmethod
424
+ def _can_coalesce(cls, last, draw_state, old, new, now, edit):
425
+ """Whether this edit (old -> new) should fold into `last` instead of
426
+ starting a new step. `edit` is the _TypingEdit for keystroke-sized
427
+ text edits; None for non-text values and for standalone text edits."""
428
+ if last is None or last.draw_state is not draw_state or last.sealed:
429
+ return False
430
+ if isinstance(old, str):
431
+ if edit is None: # paste / Enter / replace
432
+ return False
433
+ return cls._typing_continues(last, old, edit) is not None
434
+ if now - last.t > cls.COALESCE_WINDOW: # pause -> commit group
435
+ return False
436
+ # Non-text (numbers/tuples): one for a continuous drag - a held
437
+ # widget streams a value every frame. Exact value-contiguity is the
438
+ # wrong test here: a drag_float returns float32-precision values
439
+ # (3.828000068664551) that read back next frame as a clean 3.828, so
440
+ # `last.new == old` on every frame and each frame becomes its own
441
+ # undo step. The reliable "one gesture" signal is the widget being
442
+ # actively dragged; discrete clicks/taps aren't held, so they
443
+ # stay discrete undo steps.
444
+ return _gesture_live(draw_state)
445
+
446
+ @classmethod
447
+ def _typing_continues(cls, last, old, edit):
448
+ """The text rule: `edit` extends the typing run `last` holds. Value
449
+ contiguity (an undo / external write in between shows as a mismatch),
450
+ same direction, position contiguity (the edit touches the run's caret
451
+ edge — an insert right at it, a Backspace ending at it or a Delete
452
+ starting at it), and no new word starting inside `edge_char + typed`
453
+ (typed in typing order, so a Backspace run reads its removed text
454
+ reversed). Returns None to start a new step, else the resolved
455
+ buffer offset the edit really happened at (the diff's span is the
456
+ rightmost of its equivalent placements — deleting one 'l' of "ll"
457
+ reads at the second 'l' — and `_span_reaches` slides it back to the
458
+ run's edge)."""
459
+ if last.new != old or last.direction != edit.kind or last.edit_end is None:
460
+ return None
461
+ if edit.kind == "insert":
462
+ if not _span_reaches(old, edit, last.edit_end):
463
+ return None
464
+ pos, typed = last.edit_end, edit.text
465
+ elif _span_reaches(old, edit, last.edit_end - len(edit.text)): # Backspace
466
+ pos, typed = last.edit_end - len(edit.text), edit.text[::-1]
467
+ elif _span_reaches(old, edit, last.edit_end): # Delete key
468
+ pos, typed = last.edit_end, edit.text
469
+ else:
470
+ return None # edited elsewhere
471
+ from meltygui.core.runtime.toggles import Toggles
472
+ if Toggles.CodeEditor.undo_word_steps and _word_starts(last.edge_char + typed):
473
+ return None
474
+ return pos
475
+
476
+ @classmethod
477
+ def record(cls, draw_state, old, new, setter=None, key=None, label=None):
478
+ """Log one edit. `setter` (with `key`, `label`) makes it a
479
+ SetterChange — an editor with no wrapper of its own (draw_tuple_fast)
480
+ supplies the write undo/redo must make; `key` tells the chips that
481
+ share one draw_state apart for coalescing."""
482
+ if Core.melty.frame_count < cls.settle_for:
483
+ return
484
+ # Collection mutations (drag-drop reorders, see drag_drop.py) are
485
+ # frozen "insert x at key y"-style records - safe to hold by
486
+ # reference like the approved primitives, and the whole point of
487
+ # them is not snapshotting the dict they edit. `old` is the
488
+ # inverse mutation, `new` the applied one; undo/redo apply either
489
+ # side to the live collection via the wrapper-tail interception.
490
+ is_mutation = (getattr(old, "__collection_mutation__", False)
491
+ and getattr(new, "__collection_mutation__", False))
492
+ if not is_mutation and not (isinstance(old, cls.APPROVED_TYPES)
493
+ and isinstance(new, cls.APPROVED_TYPES)):
494
+ return
495
+ # Skip no- change. Several renderers (draw_text, draw_collection, the
496
+ # @window source views) report changed=True every frame with old == new.
497
+ # Logging those floods the bounded history deque and evicts the real
498
+ # edits, so undo ends up restoring an identical value (a visible no-op).
499
+ # An undo entry where nothing changed is pointless by definition.
500
+ try:
501
+ if old == new:
502
+ return
503
+ except Exception:
504
+ pass
505
+
506
+ # A genuine new edit (only origins reach here; undo/redo restores bypass
507
+ # record().) diverges from any undone changes - drop redo.
508
+ cls.redo_stack.clear()
509
+
510
+ now = time.time()
511
+ frame = Core.melty.frame_count
512
+ is_text = isinstance(old, str) and isinstance(new, str)
513
+ edit = _typing_edit(old, new) if is_text else None
514
+ if edit is not None:
515
+ direction = edit.kind
516
+ else:
517
+ direction = _edit_direction(old, new) if is_text else None
518
+ # Post-edit caret: record() runs in the wrapper frame (after the body), so
519
+ # the draw_state's caret now reflects the result of this edit. Redo needs it.
520
+ ui_after = draw_state.capture_undo_state() if hasattr(draw_state, "capture_undo_state") else None
521
+
522
+ # Fold target: the change for THIS draw_state in the TOP group. Only the
523
+ # top group: any other user action landing on the stack (another page's
524
+ # step, a non-text change) closes the typing run, as in Excel. Views
525
+ # responding to the SAME keystroke share its group (GROUP_FRAME_WINDOW), so
526
+ # a cascade landing between two keystrokes doesn't fold them - searching
527
+ # the group rather than just history[-1] is what keeps that in happening.
528
+ target = None
529
+ top_gid = cls.history[-1].group_id if cls.history else None
530
+ for c in reversed(cls.history):
531
+ if c.group_id != top_gid:
532
+ break
533
+ if c.draw_state is draw_state and getattr(c, "key", None) == key:
534
+ target = c
535
+ break
536
+ if target is not None and cls._can_coalesce(target, draw_state, old, new, now, edit):
537
+ if edit is not None:
538
+ pos = cls._typing_continues(target, old, edit)
539
+ target.edit_end, target.edge_char = _run_edge(edit, pos, target.edit_end)
540
+ target.new = new
541
+ target.t = now
542
+ target.ui_after = ui_after
543
+ target.frame = frame
544
+ return
545
+
546
+ # New change. Join the previous change's group when they're within
547
+ # GROUP_FRAME_WINDOW frames (same user action / cascade) - unless that
548
+ # group already holds a change for this draw_state, in which case this is a
549
+ # fresh semantic step (e.g. a new word boundary) and gets its own group.
550
+ gid = None
551
+ last = cls.history[-1] if cls.history else None
552
+ if last is not None and (frame - last.frame) <= cls.GROUP_FRAME_WINDOW:
553
+ gid = last.group_id
554
+ for c in reversed(cls.history):
555
+ if c.group_id != gid:
556
+ break
557
+ if c.draw_state is draw_state and getattr(c, "key", None) == key:
558
+ gid = None # ds already in this group -> new group
559
+ break
560
+ if gid is None:
561
+ gid = cls.stack.new_group_id()
562
+
563
+ if edit is not None:
564
+ # A run's first edit: the diff's span is ambiguous next to repeated
565
+ # characters, so trust the post-edit caret when it names one of the
566
+ # equivalent placements (rendered display text can put the caret in
567
+ # other coordinates - then it's none and the diff's own stands).
568
+ caret = ui_after.get("text_cursor_pos") if ui_after else None
569
+ pos = edit.pos
570
+ if isinstance(caret, int):
571
+ want = caret - len(edit.text) if edit.kind == "insert" else caret
572
+ if _span_reaches(old, edit, want):
573
+ pos = want
574
+ edit_end, edge_char = _run_edge(edit, pos, None)
575
+ else:
576
+ edit_end, edge_char = None, ""
577
+ if setter is not None:
578
+ change = SetterChange(draw_state, old, new, setter, key=key, label=label,
579
+ t=now, group_id=gid, frame=frame)
580
+ else:
581
+ change = Change(draw_state, old, new, ui=getattr(draw_state, "_undo_pre", None),
582
+ t=now, direction=direction, ui_after=ui_after, group_id=gid, frame=frame,
583
+ edit_end=edit_end, edge_char=edge_char,
584
+ sealed=is_text and edit is None) # paste / Enter / replace: own group
585
+ cls.history.append(change)
586
+
587
+ # while len(cls.history) > cls.MAX_HISTORY:
588
+ # evicted = cls.history.popleft()
589
+ # per_node = cls.change_history.get(evicted.draw_state)
590
+ # if per_node:
591
+ # per_node.remove(evicted)
592
+ # if not per_node:
593
+ # del cls.change_history[evicted.draw_state]
594
+
595
+
596
+ class NavUndo:
597
+ """The navigation timeline — a separate UndoStack from text edits, so
598
+ stepping back through WHERE you were never touches WHAT you typed.
599
+ Records location moves (editor tab switches, jump-tos), window
600
+ open/close toggles, and caret / text-focus steps (poll_caret, once per
601
+ frame from Melty.end_frame). Driven by Ctrl+Shift+Left/Right (root
602
+ handler in new_core_view) and the Fast Dock back/forward buttons. Gated
603
+ on Toggles.CodeEditor.undo_navigation (caret steps additionally on
604
+ undo_navigation_caret)."""
605
+
606
+ stack = UndoStack("navigation")
607
+
608
+ # Reentrancy guard: while undo/redo replays, the navigation it triggers
609
+ # (open_in_editor → tab select + jump-to, window closed writes) should not
610
+ # record fresh entries.
611
+ _restoring = False
612
+
613
+ # Caret detector state: where the focused draw_text's caret was at the
614
+ # last poll (a CaretLocation), and the frame up to which caret moves are
615
+ # NOT recorded - armed by every other record_* (a jump / tab switch moves
616
+ # the caret itself; the nav step already covers it), by the jump
617
+ # consumer in draw_code_editor (quiet_caret), and by undo/redo (a replay
618
+ # moves the caret too).
619
+ _last_caret = None
620
+ _caret_quiet_until = -1
621
+
622
+ @classmethod
623
+ def _recordable(cls):
624
+ from meltygui.core.runtime.toggles import Toggles
625
+ return (Toggles.CodeEditor.undo_navigation and not cls._restoring
626
+ and Core.melty.frame_count >= UndoManager.settle_for)
627
+
628
+ @classmethod
629
+ def quiet_caret(cls, frames=None):
630
+ """Don't record caret moves for the next `frames` frames (default
631
+ UndoManager.GROUP_FRAME_WINDOW): the move about to happen belongs to
632
+ a navigation step that is recorded (or replayed) on its own."""
633
+ if frames is None:
634
+ frames = UndoManager.GROUP_FRAME_WINDOW
635
+ cls._caret_quiet_until = max(cls._caret_quiet_until,
636
+ Core.melty.frame_count + frames)
637
+
638
+ @classmethod
639
+ def record_location(cls, old_loc, new_loc):
640
+ """Push a location step. Locations are (path, line, instance) tuples
641
+ (line may be None; instance is which code-editor window — replay must
642
+ land in the SAME window, not the primary). Each step is its own
643
+ group."""
644
+ cls.quiet_caret()
645
+ if not cls._recordable():
646
+ return
647
+ if old_loc == new_loc:
648
+ return
649
+ # Re-selecting the already-selected file with no target line moves
650
+ # nothing - not worth an undo step. Same file in the OTHER editor
651
+ # instance is a real move (the [2:] slice keeps pre-instance 2-tuples
652
+ # comparing equal).
653
+ if (new_loc[1] is None and old_loc[0] == new_loc[0]
654
+ and old_loc[2:] == new_loc[2:]):
655
+ return
656
+ cls.stack.push(NavChange(old_loc, new_loc, t=time.time(),
657
+ group_id=cls.stack.new_group_id(),
658
+ frame=Core.melty.frame_count))
659
+
660
+ @classmethod
661
+ def record_compare(cls, instance, old_token, new_token, repo_root=None):
662
+ """Push a Compare-With selection step (editor dropdown / clear ×)."""
663
+ from meltygui.core.runtime.extensions import call
664
+ call('compare_history', instance, old_token, new_token, repo_root=repo_root)
665
+
666
+ @classmethod
667
+ def record_window(cls, window_ds, closed_before, closed_after):
668
+ """Push a window open/close step (the user toggled `closed` — dock row
669
+ click, chrome ×)."""
670
+ cls.quiet_caret()
671
+ if not cls._recordable() or closed_before == closed_after:
672
+ return
673
+ cls.stack.push(WindowChange(window_ds, closed_before, closed_after,
674
+ t=time.time(),
675
+ group_id=cls.stack.new_group_id(),
676
+ frame=Core.melty.frame_count))
677
+
678
+ @classmethod
679
+ def record_window_move(cls, window_ds, old_pos, new_pos):
680
+ """Push a window-move step (the end of one hand drag). Consecutive
681
+ drags of the same window each get their own step — a drag is already
682
+ the natural gesture unit, no coalescing."""
683
+ cls.quiet_caret()
684
+ if not cls._recordable():
685
+ return
686
+ if (old_pos[0], old_pos[1]) == (new_pos[0], new_pos[1]):
687
+ return
688
+ cls.stack.push(WindowMoveChange(window_ds, old_pos, new_pos,
689
+ t=time.time(),
690
+ group_id=cls.stack.new_group_id(),
691
+ frame=Core.melty.frame_count))
692
+
693
+ # ── Caret / text-focus detection ─────────────────────────────────────────
694
+
695
+ @classmethod
696
+ def poll_caret(cls):
697
+ """Once per frame (Melty.end_frame, after apply_move_to_front settled
698
+ text focus): compare the focused draw_text's caret with the last
699
+ poll and record a CaretChange when it moved — a caret move inside
700
+ one view, or text focus hopping to another view (the old side is
701
+ the view that HELD focus last, even if focus was cleared meanwhile).
702
+ Cheap on the steady state: one 4-tuple compare. Never records
703
+ during the quiet frames a jump / tab switch / replay armed, nor the
704
+ caret move of a text EDIT (the edit stack owns that — typing would
705
+ otherwise leave a nav step at every pause)."""
706
+ draw_state = Core.melty.text_focused_ds
707
+ if draw_state is None or getattr(draw_state, "is_search_box", False):
708
+ return
709
+ last = cls._last_caret
710
+ key = (draw_state._tile_id, draw_state.text_cursor_pos,
711
+ draw_state.text_selection_start, draw_state.text_selection_end)
712
+ if last is not None and last.key == key:
713
+ return
714
+ current = cls._caret_location(draw_state)
715
+ cls._last_caret = current
716
+ if last is None or not cls._recordable():
717
+ return
718
+ from meltygui.core.runtime.toggles import Toggles
719
+ if not Toggles.CodeEditor.undo_navigation_caret:
720
+ return
721
+ frame = Core.melty.frame_count
722
+ if frame <= cls._caret_quiet_until:
723
+ return
724
+ if cls._edited_recently(draw_state, frame):
725
+ return
726
+ cls.record_caret(last, current)
727
+
728
+ @classmethod
729
+ def _caret_location(cls, draw_state):
730
+ """Snapshot `draw_state`'s caret as a CaretLocation. A code-editor pane (the
731
+ selected tab of an editor instance, per open_files._active_editors)
732
+ carries its path/instance so replay can re-select the tab, and its
733
+ FULL-buffer caret line (the caret offset lives in fold display
734
+ space). Other draw_texts count lines in their raw input when it's a
735
+ string; line None = coalesce on time alone."""
736
+ pos = draw_state.text_cursor_pos
737
+ path, instance, text = None, 0, None
738
+ from meltygui.core.runtime.extensions import source_views
739
+ _active_editors = source_views()
740
+ for editor_instance, (editor_path, pane, held_text) in _active_editors.items():
741
+ if pane is draw_state:
742
+ path, instance, text = editor_path, editor_instance, held_text
743
+ break
744
+ line = None
745
+ if path is not None and isinstance(text, str):
746
+ from meltygui.editor.text_editor import fold_buffer_line_at
747
+ line = fold_buffer_line_at(draw_state, text, max(0, min(pos, len(text))))
748
+ else:
749
+ raw = getattr(draw_state, "_raw_input_value", None)
750
+ if isinstance(raw, str):
751
+ line = raw.count("\n", 0, max(0, min(pos, len(raw))))
752
+ return CaretLocation(draw_state, pos, draw_state.text_selection_start,
753
+ draw_state.text_selection_end, path=path,
754
+ instance=instance, line=line)
755
+
756
+ @classmethod
757
+ def _edited_recently(cls, draw_state, frame):
758
+ """Whether the edit stack recorded a change to `draw_state` within the last
759
+ GROUP_FRAME_WINDOW frames — i.e. this caret move came from typing /
760
+ deleting, not from navigating."""
761
+ seen = 0
762
+ for change in reversed(UndoManager.history):
763
+ if frame - change.frame > UndoManager.GROUP_FRAME_WINDOW:
764
+ break
765
+ if change.draw_state is draw_state:
766
+ return True
767
+ seen += 1
768
+ if seen >= 8:
769
+ break
770
+ return False
771
+
772
+ @classmethod
773
+ def record_caret(cls, old_loc, new_loc):
774
+ """Push a caret step, or fold it into the newest one: consecutive
775
+ moves in the SAME view within Toggles.CodeEditor.nav_caret_coalesce_s
776
+ seconds of each other that stay within nav_caret_step_lines lines of
777
+ where the step already ended advance that step's `new` (an arrow-key
778
+ walk is one step back), while `old` stays pinned to where the walk
779
+ began. A far move (a click elsewhere, Ctrl+End) or a pause starts a
780
+ new step; so does any move after an undo (a fold would keep a
781
+ diverged redo alive)."""
782
+ if old_loc == new_loc:
783
+ return
784
+ from meltygui.core.runtime.toggles import Toggles
785
+ now = time.time()
786
+ frame = Core.melty.frame_count
787
+ top = cls.stack.history[-1] if cls.stack.history else None
788
+ if (isinstance(top, CaretChange) and not cls.stack.redo_stack
789
+ and top.new.tile_id == new_loc.tile_id == old_loc.tile_id
790
+ and now - top.t <= Toggles.CodeEditor.nav_caret_coalesce_s
791
+ and (top.new.line is None or new_loc.line is None
792
+ or abs(top.new.line - new_loc.line)
793
+ <= Toggles.CodeEditor.nav_caret_step_lines)):
794
+ top.new = new_loc
795
+ top.t = now
796
+ top.frame = frame
797
+ return
798
+ cls.stack.push(CaretChange(old_loc, new_loc, t=now,
799
+ group_id=cls.stack.new_group_id(),
800
+ frame=frame))
801
+
802
+ @classmethod
803
+ def _apply_caret(cls, location):
804
+ """Replay one side of a CaretChange: bring the view on screen (its
805
+ editor tab / window), put the caret back and hand it text focus.
806
+ The view's own caret-follow scroll brings the caret into view on its
807
+ next body run (text_cursor_pos != text_prev_cursor_pos)."""
808
+ draw_state = location.draw_state()
809
+ if draw_state is None:
810
+ return
811
+ from meltygui.core.runtime.extensions import source_views
812
+ _active_editors = source_views()
813
+ from meltygui.core.runtime.extensions import source_window as editor_window_draw_state
814
+ if location.path:
815
+ active = _active_editors.get(location.instance)
816
+ if active is None or active[0] != location.path:
817
+ # Another tab was selected in that editor instance: a line-less
818
+ # location replay selects it (and raises its window) without
819
+ # touching the caret - the write below is what lands it.
820
+ cls._apply_location((location.path, None, location.instance))
821
+ else:
822
+ win = editor_window_draw_state(location.instance)
823
+ if win is not None:
824
+ win.closed = False
825
+ Core.melty.move_window_to_front(win)
826
+ else:
827
+ # Reopen any closed window on the view's parent chain, then raise
828
+ # the innermost one (move_window_to_front raises its whole chain).
829
+ innermost = None
830
+ node, steps = draw_state.parent_window, 0
831
+ while node is not None and steps < 16:
832
+ if innermost is None:
833
+ innermost = node
834
+ if node.closed:
835
+ node.closed = False
836
+ if Core.melty.cache is not None and node._tile_id is not None:
837
+ Core.melty.cache.invalidate_up(node._tile_id, force=True,
838
+ max_depth=4)
839
+ next_window = node.parent_window
840
+ if next_window is node:
841
+ break
842
+ node, steps = next_window, steps + 1
843
+ if innermost is not None:
844
+ Core.melty.move_window_to_front(innermost)
845
+ draw_state.text_cursor_pos = location.cursor
846
+ draw_state.text_selection_start = location.selection_start
847
+ draw_state.text_selection_end = location.selection_end
848
+ draw_state.text_cursor_blink_time = time.time()
849
+ Core.melty.text_focused_ds = draw_state
850
+ Core.melty._text_focus_grant_frame = Core.melty.frame_count
851
+ # The replayed position is the new location - next poll must not read
852
+ # it back as a fresh move (quiet only all the frames in between).
853
+ cls._last_caret = location
854
+ cache = getattr(Core.melty, "cache", None)
855
+ if cache is not None and draw_state._tile_id is not None:
856
+ cache.invalidate_up(draw_state._tile_id, force=True)
857
+ request_render()
858
+
859
+ @classmethod
860
+ def _apply_location(cls, loc):
861
+ from meltygui.core.runtime.extensions import get, open_source
862
+ provider = get('source_location')
863
+ if provider is not None:
864
+ return provider(loc)
865
+ if loc and loc[0]:
866
+ return open_source(loc[0], line_number=loc[1] if len(loc) > 1 else None)
867
+
868
+ @classmethod
869
+ def undo(cls):
870
+ cls._restoring = True
871
+ cls.quiet_caret()
872
+ try:
873
+ cls.stack.undo()
874
+ finally:
875
+ cls._restoring = False
876
+
877
+ @classmethod
878
+ def redo(cls):
879
+ cls._restoring = True
880
+ cls.quiet_caret()
881
+ try:
882
+ cls.stack.redo()
883
+ finally:
884
+ cls._restoring = False
885
+
886
+
887
+ def _edit_direction(old, new):
888
+ """Classify a text edit by length: 'insert' grew, 'delete' shrank, 'replace'
889
+ kept the same length (e.g. overwriting a selection)."""
890
+ if len(new) > len(old):
891
+ return "insert"
892
+ if len(new) < len(old):
893
+ return "delete"
894
+ return "replace"
895
+
896
+
897
+ def _diff_span(old, new):
898
+ """Minimal differing span as (prefix_len, removed_text, inserted_text).
899
+ Strips the common prefix and suffix so `removed` is the run `old` loses
900
+ and `inserted` the run `new` adds at offset `prefix_len`.
901
+
902
+ Chunked: equal 4KB slices skip at C memcmp speed, per-char refinement only
903
+ inside the first mismatching chunk. The original per-char Python walk was
904
+ O(buffer) per FOLDED INSERT — record() runs it via _typing_edit on
905
+ every keystroke, and on a ~166KB buffer that was a
906
+ measured ~20ms slice of the edited-frame wrapper epilogue (held-Enter
907
+ bursts; alternating insert/delete never reached it, which is why the cost
908
+ came and went between sessions)."""
909
+ lo, ln = len(old), len(new)
910
+ m = min(lo, ln)
911
+ chunk = 4096
912
+ p = 0
913
+ while p < m:
914
+ step = min(chunk, m - p)
915
+ if old[p:p + step] == new[p:p + step]:
916
+ p += step
917
+ continue
918
+ e = p + step
919
+ while p < e and old[p] == new[p]:
920
+ p += 1
921
+ break
922
+ s = 0
923
+ ms = m - p
924
+ while s < ms:
925
+ step = min(chunk, ms - s)
926
+ if old[lo - s - step:lo - s] == new[ln - s - step:ln - s]:
927
+ s += step
928
+ continue
929
+ e = s + step
930
+ while s < e and old[lo - 1 - s] == new[ln - 1 - s]:
931
+ s += 1
932
+ break
933
+ return p, old[p:lo - s], new[p:ln - s]
934
+
935
+
936
+ class _TypingEdit:
937
+ """A keystroke-sized text edit: `kind` 'insert' or 'delete', `pos` the
938
+ buffer offset it starts at (in `old`), `text` the run inserted or removed
939
+ in BUFFER order (a Backspace run is read reversed where typing order
940
+ matters)."""
941
+
942
+ __slots__ = ("kind", "pos", "text")
943
+
944
+ def __init__(self, kind, pos, text):
945
+ self.kind = kind
946
+ self.pos = pos
947
+ self.text = text
948
+
949
+
950
+ def _typing_edit(old, new):
951
+ """Classify a text edit as typing, or None for a standalone step. Typing
952
+ is a pure insert or a pure delete of at most
953
+ Toggles.CodeEditor.undo_typing_max_chars characters with no newline in
954
+ it. Everything else is its own undo step, like the separate editor
955
+ commands they come from in IntelliJ: Enter (+ auto-indent), Tab, a
956
+ paste, a completion, a comment toggle, a selection typed over or cut."""
957
+ from meltygui.core.runtime.toggles import Toggles
958
+ pos, removed, inserted = _diff_span(old, new)
959
+ if removed and inserted:
960
+ return None
961
+ text = inserted or removed
962
+ if not text or "\n" in text or len(text) > Toggles.CodeEditor.undo_typing_max_chars:
963
+ return None
964
+ return _TypingEdit("insert" if inserted else "delete", pos, text)
965
+
966
+
967
+ def _span_reaches(old, edit, pos):
968
+ """Whether `edit`, whose diff span sits at `edit.pos`, could equally have
969
+ happened at `pos`. The common-prefix diff reports the RIGHTMOST
970
+ equivalent placement: inserting 'l' before the 'l' of "helo" reads as an
971
+ insert after it, deleting the first 'l' of "hello" as deleting the
972
+ second. Sliding the span left by one is equivalent whenever the character
973
+ it slides over equals the run's last (cyclically, for a multi-character
974
+ run), so the check walks back from edit.pos to `pos` — O(shift), and the
975
+ shift is the length of the repeated-character run."""
976
+ if pos == edit.pos:
977
+ return True
978
+ if pos < 0 or pos > edit.pos:
979
+ return False
980
+ text = edit.text
981
+ length = len(text)
982
+ for j in range(edit.pos - pos):
983
+ if old[edit.pos - 1 - j] != text[-1 - (j % length)]:
984
+ return False
985
+ return True
986
+
987
+
988
+ def _run_edge(edit, pos, prev_end):
989
+ """Where a typing run ends once `edit` (resolved to buffer offset `pos`)
990
+ joins it — (edit_end, edge_char). `edit_end` is the caret offset after
991
+ the edit (an insert ends past its text; a delete leaves the caret at its
992
+ start either way). `edge_char` is the last character typed in typing
993
+ order: an insert's last, a Delete-key run's last removed, a Backspace
994
+ run's FIRST removed (it removes backwards). A run's first delete has no
995
+ edge to compare against and reads as Backspace, the common case."""
996
+ if edit.kind == "insert":
997
+ return pos + len(edit.text), edit.text[-1]
998
+ backspace = prev_end is None or pos + len(edit.text) == prev_end
999
+ return pos, (edit.text[0] if backspace else edit.text[-1])
1000
+
1001
+
1002
+ def _word_starts(run):
1003
+ """True when a new word starts anywhere inside `run` — a non-space right
1004
+ after whitespace — which closes the current step so each word is its own
1005
+ undo (typing 'hello world' -> 'hello ' | 'world'; backspacing it from the
1006
+ end -> ' world' | 'hello'). `run` is the step's edge char followed by the
1007
+ new keystrokes in typing order."""
1008
+ for i in range(1, len(run)):
1009
+ if run[i - 1].isspace() and not run[i].isspace():
1010
+ return True
1011
+ return False
1012
+
1013
+
1014
+ def _gesture_live(draw_state):
1015
+ """Whether `draw_state`'s value is mid-gesture: its own imgui item is
1016
+ active (a held drag_float), or it OWNS the open popover in which an
1017
+ imgui item is active — draw_tuple's colour picker, draw_tuple_fast's:
1018
+ the picker window is parented under the editor (Melty.popover_focused_ds
1019
+ names the editor, or the picker whose parent chain reaches it), and a
1020
+ hue/SV drag there is one gesture on the editor's value."""
1021
+ if getattr(draw_state, "_imgui_is_active", False):
1022
+ return True
1023
+ if not getattr(Core.melty, "imgui_any_item_active", False):
1024
+ return False
1025
+ node, steps = getattr(Core.melty, "popover_focused_ds", None), 0
1026
+ while node is not None and steps < 64:
1027
+ if node is draw_state:
1028
+ return True
1029
+ parent = getattr(node, "parent_window", None)
1030
+ if parent is None or parent is node:
1031
+ return False
1032
+ node, steps = parent, steps + 1
1033
+ return False
1034
+
1035
+
1036
+ # The primitive value editors - the genuine leaves of the render tree. Their
1037
+ # changes originate from direct user interaction (not bubbled up from a child),
1038
+ # so they're always valid undo origins. Needed because imgui status flags aren't
1039
+ # reliable across widget types: drag_float sets is_item_edited but checkbox does
1040
+ # not, so a flag-only origin check silently dropped every boolean toggle.
1041
+ LEAF_EDITOR_FUNCS = frozenset({"draw_bool", "draw_str", "draw_float", "draw_int", "draw_text",
1042
+ "draw_tuple"})
1043
+ # Sub-widgets a leaf editor owns: their draw_state reports the same change
1044
+ # a frame earlier (the colour picker popover under draw_tuple) and must never
1045
+ # be the origin - the owning leaf records first, so undo lands on the leaf.
1046
+ NEVER_ORIGIN_FUNCS = frozenset({"draw_color_picker"})
1047
+
1048
+
1049
+ def _is_leaf_editor(draw_state):
1050
+ return getattr(getattr(draw_state, "_view_func", None), "__name__", None) in LEAF_EDITOR_FUNCS
1051
+
1052
+
1053
+ def _never_origin(draw_state):
1054
+ return getattr(getattr(draw_state, "_view_func", None), "__name__", None) in NEVER_ORIGIN_FUNCS
1055
+
1056
+
1057
+ def handle_undo(changed, old_value, new_value, draw_state):
1058
+ if not changed:
1059
+ return
1060
+ # Only the widget the user directly interacted with may record an undo entry.
1061
+ # A single edit bubbles up through every render_func chain - the focused text
1062
+ # editor (draw_text) and its converter/wrapper ancestors (code_to_io_wrapped,
1063
+ # collections) all report the same string change, often on different frames
1064
+ # because conversion runs async. Those pass-through draw_states must never
1065
+ # become undo-stack elements; only the interaction origin may: a primitive
1066
+ # value editor, the focused text editor, or the imgui-active widget this frame.
1067
+ # A container/converter is none of these.
1068
+ is_origin = (_is_leaf_editor(draw_state)
1069
+ or draw_state is Core.melty.text_focused_ds
1070
+ or getattr(draw_state, "_imgui_is_edited", False))
1071
+ if not is_origin or _never_origin(draw_state):
1072
+ return
1073
+ UndoManager.record(draw_state, old_value, new_value)
1074
+
1075
+