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
meltygui/text_index.py ADDED
@@ -0,0 +1,816 @@
1
+ """Mmapped trigram full-text index — the engine behind global search's Text tab.
2
+
3
+ Design (the Google-Code-Search / ripgrep-index lineage), so search cost stays
4
+ O(results) instead of O(corpus):
5
+
6
+ segment — an immutable on-disk index file, mmapped read-only: sorted uint32
7
+ trigram keys + fixed-width uint32 postings of file ids. A query's
8
+ trigrams intersect their posting lists down to a small candidate
9
+ set; only candidates are read and verified.
10
+ overlay — files whose CURRENT text may disagree with the segment (unsaved
11
+ pending edits, external disk changes, brand-new files). Kept as a
12
+ dirty SET, not a text copy: dirty files are linearly scanned at
13
+ query time with pending-aware text. The set stays small between
14
+ rebuilds; growing past _REBUILD_DIRTY triggers a background
15
+ rebuild that folds the overlay back into a fresh segment.
16
+
17
+ Invalidation is content-free: the per-file signature is
18
+ (st_mtime_ns, st_size, pending_gen) — disk edits move the stat pair, in-app
19
+ edits bump PendingSave._pending_gen. Signatures are compared lazily on a
20
+ short TTL when a search actually runs; nothing walks or stats while search
21
+ is idle.
22
+
23
+ Pending edits are the source of truth: both indexing and match verification
24
+ read through PendingSave.current_file_text (disk with every unsaved span
25
+ edit spliced in), so hits reflect what the editor shows and hit line numbers
26
+ are in PENDING coordinates — the same coordinates the editor buffer uses, so
27
+ jumps land where the match is.
28
+
29
+ Restart-in-place: live state (segment mmap, dirty set, locks) is a plain
30
+ dict adopted through sys — shared across re-execs and module hotswaps. A
31
+ full process restart just re-mmaps the segment file; only the staleness
32
+ sweep re-runs cold.
33
+
34
+ Scale notes: search scales to multi-GB corpora (posting intersection +
35
+ candidate-only reads). The BUILD is the v1 shortcut — one in-memory numpy
36
+ sort of all (trigram, file) pairs — fine to roughly a GB of text; beyond
37
+ that it should shard into merged sub-segments (the search path would not
38
+ change). Postings are fixed uint32 (no varint) — ~4x larger on disk than
39
+ delta-varint, but mmap only pages in what queries touch.
40
+ """
41
+
42
+ import hashlib
43
+ import mmap
44
+ import os
45
+ import pickle
46
+ import struct
47
+ import sys
48
+ import threading
49
+ import time
50
+ import traceback
51
+ from pathlib import Path
52
+
53
+ import numpy as np
54
+
55
+ _MAGIC = b"LSDTGI4\n" # v2: symbol tables; v3: line tints; v4: raw
56
+ # ( (stripped) file/sigils; search rows render
57
+ # through draw_text, whose usage syntaxes map by
58
+ # file path, so buffers must keep their indent
59
+ # magic, n_len, meta_len, n_tri, keys_off, offs_off, post_off
60
+ _HDR = struct.Struct("<8s6Q")
61
+
62
+ _TEXT_EXTS = {
63
+ ".py", ".pyi", ".md", ".txt", ".rst", ".ini", ".cfg", ".toml", ".json",
64
+ ".yaml", ".yml", ".sh", ".glsl", ".frag", ".vert", ".comp", ".c", ".h",
65
+ ".cpp", ".hpp", ".cu", ".cuh", ".js", ".ts", ".css", ".html", ".xml",
66
+ }
67
+ _SKIP_DIRS = {"__pycache__", ".git", ".hg", ".idea", ".vscode", "node_modules",
68
+ ".venv", "venv", ".mypy_cache", ".pytest_cache"}
69
+ _MAX_FILE_BYTES = 8 << 20 # bigger than this → skipped (generated blobs)
70
+ _STAT_TTL = 5.0 # seconds between staleness stat sweeps
71
+ _WALK_TTL = 30.0 # seconds between new-file directory walks
72
+ _REBUILD_DIRTY = 64 # overlay size that triggers a background rebuild
73
+ _PER_FILE_CAP = 5 # max content hits reported per file
74
+ _CAND_CAP = 4000 # max segment candidates verified per query
75
+ _FILE_HIT_CAP = 20 # max FILE-NAME hits per query
76
+ _SYMBOL_HIT_CAP = 40 # max symbol-content hits per query
77
+
78
+
79
+ # ── Roots and paths ───────────────────────────────────────────────────────────
80
+
81
+ def _search_root() -> str:
82
+ """The same src root the Files tab's labels are relative to."""
83
+ from meltygui.code.libcst_conversion import _SRC_PREFIX
84
+ return str(Path(_SRC_PREFIX))
85
+
86
+
87
+ def _segment_file(root: str) -> Path:
88
+ d = Path.home() / ".lsd" / "text_index"
89
+ d.mkdir(parents=True, exist_ok=True)
90
+ return d / (hashlib.sha1(root.encode()).hexdigest()[:16] + ".tgi")
91
+
92
+
93
+ def _under(path: str, root: str) -> bool:
94
+ """`path` is `root` or inside it — a DIRECTORY prefix. A bare
95
+ str.startswith put /a/bc/x.py under root /a/b once sibling projects
96
+ were open side by side."""
97
+ return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
98
+
99
+
100
+ def _resolve_root(root) -> str:
101
+ """The index root as a str: the caller's `root` (a meltygui app's project),
102
+ else the src root. Every index entry point takes an optional root so a
103
+ standalone app searches ITS files, not the framework checkout."""
104
+ return str(root) if root else _search_root()
105
+
106
+
107
+ def warm(root=None):
108
+ """Build (or load) the segment for `root` ahead of the first search, so
109
+ the first keystroke doesn't pay the walk. Call from a background thread."""
110
+ root = _resolve_root(root)
111
+ st = _state(root)
112
+ _ensure_segment(st, root)
113
+ _sweep(st, root)
114
+
115
+
116
+ def symbol_tables(root=None):
117
+ """The class/def tables of every indexed .py file under `root`, pending
118
+ edits included: ``(key, [(rel, symbols)])`` with `symbols` in the
119
+ _extract_symbols shape ``(name, line, indent, kind, tint, end, sig)`` and
120
+ every indexed file listed (non-.py files with an empty table). `key` is
121
+ a cheap identity for memoizing anything derived from the tables — it
122
+ changes when the segment is rebuilt, a file appears / goes stale, or an
123
+ edit is queued. Overlay (dirty / new) files are extracted live and
124
+ shadow their segment entries. Builds the index on first use: call from
125
+ a background thread (the global-search worker does)."""
126
+ root = _resolve_root(root)
127
+ st = _state(root)
128
+ _ensure_segment(st, root)
129
+ _sweep(st, root)
130
+ _maybe_rebuild(st, root)
131
+ seg = st["seg"]
132
+ overlay = sorted(st["dirty"] | st["extra"])
133
+ gens = _pending_gens()
134
+ overlay_sigs = []
135
+ for ap in overlay:
136
+ try:
137
+ stat = os.stat(ap)
138
+ sig = (stat.st_mtime_ns, stat.st_size)
139
+ except OSError:
140
+ sig = None
141
+ overlay_sigs.append((ap, sig, gens.get(ap, 0)))
142
+ key = (root, id(seg), tuple(overlay_sigs))
143
+ memo = st.get("symbol_tables_memo")
144
+ if memo is not None and memo[0] == key:
145
+ return memo
146
+ out = []
147
+ seen = set()
148
+ for ap, disk_sig, pending_gen in overlay_sigs:
149
+ rel = os.path.relpath(ap, root)
150
+ seen.add(rel)
151
+ if disk_sig is None and not pending_gen:
152
+ continue
153
+ text = _current_text(ap)
154
+ out.append((rel, _extract_symbols(text, rel.endswith(".py")) if text else []))
155
+ if seg is not None:
156
+ for rel, syms in zip(seg.paths, seg.symbols):
157
+ if rel not in seen:
158
+ out.append((rel, syms))
159
+ out.sort(key=lambda t: t[0])
160
+ st["symbol_tables_memo"] = (key, out)
161
+ return key, out
162
+
163
+
164
+ # ── Pending-aware reads (lazy imports: keep this module standalone-testable) ──
165
+
166
+ def _pending_gens() -> dict:
167
+ """{resolved abs path str: summed pending generation} — tiny (only files
168
+ with queued edits). Resolved here because PendingSave keys by the Address's
169
+ own Path object, which may be spelled differently."""
170
+ try:
171
+ from meltygui.editor.pending_save import PendingSave
172
+ except ImportError:
173
+ return {}
174
+ out = {}
175
+ for p, g in list(PendingSave._pending_gen.items()):
176
+ try:
177
+ out[str(Path(p).resolve())] = out.get(str(Path(p).resolve()), 0) + g
178
+ except OSError:
179
+ continue
180
+ return out
181
+
182
+
183
+ def _current_text(abs_path: str):
184
+ """The file as the app sees it: disk with unsaved span edits spliced in.
185
+ Falls back to a plain disk read outside the app (tests)."""
186
+ try:
187
+ from meltygui.editor.pending_save import PendingSave
188
+ return PendingSave.current_file_text(Path(abs_path))
189
+ except ImportError:
190
+ try:
191
+ return Path(abs_path).read_text(errors="replace")
192
+ except OSError:
193
+ return None
194
+
195
+
196
+ # ── Symbol extraction (names + definition tints) ──────────────────────────────
197
+ # Per .py file the index also carries a class/def table: (name, 1-based
198
+ # line, indent, kind, tint). Tints come from the editor's own definition-tint
199
+ # resolver (_scan_def_tint_lines - style kwarg / # [tint=...] comment /
200
+ # class-body var), so a search row is coloured exactly like the definition's
201
+ # block wash in the editor - Melty's colour coding is first-class in search:
202
+ # symbol hits carry their OWN tint, else hits the tint of the ENCLOSING
203
+ # block (indent-stack walk in _enclosing_tint).
204
+
205
+ import re as _re
206
+
207
+ _DEF_RE = _re.compile(r"^(\s*)(?:async\s+)?(class|def)\s+([A-Za-z_]\w*)")
208
+
209
+
210
+ def _scan_tint(lines, line_no, name):
211
+ """Explicit tint of the definition at 1-based line_no, or None. Uses the
212
+ editor's resolver in-app; standalone (tests) falls back to None."""
213
+ try:
214
+ from meltygui.editor.text_editor import _scan_def_tint_lines
215
+ except ImportError:
216
+ return None
217
+ try:
218
+ res = _scan_def_tint_lines(lines, line_no, name)
219
+ return tuple(res[0][:3]) if res else None
220
+ except Exception:
221
+ return None
222
+
223
+
224
+ def _extract_symbols(text: str, is_py: bool) -> list:
225
+ """[(name, line, indent, kind, tint, end)] for every class/def in a .py
226
+ buffer, in line order — line/end are a 1-based INCLUSIVE block span (end =
227
+ last line before the dedent, same block the editor's tint wash covers).
228
+ One pass: any code line at indent d closes the open defs at indent >= d.
229
+ Comment lines never close a block (a col-0 comment inside a body is
230
+ common); blank lines are skipped the same way. Non-.py files → []."""
231
+ if not is_py:
232
+ return []
233
+ lines = text.split("\n")
234
+ out, open_ix = [], []
235
+ for i, ln in enumerate(lines):
236
+ s = ln.strip()
237
+ if not s or s.startswith("#"):
238
+ continue
239
+ indent = len(ln) - len(ln.lstrip())
240
+ while open_ix and out[open_ix[-1]][2] >= indent:
241
+ out[open_ix.pop()][5] = i # 1-based inclusive: line i+1 is
242
+ m = _DEF_RE.match(ln) # the first OUTSIDE the block
243
+ if m is not None:
244
+ out.append([m.group(3), i + 1, indent, m.group(2),
245
+ _scan_tint(lines, i + 1, m.group(3)), len(lines),
246
+ ln.rstrip()[:160]]) # sig: the entire def line (widt
247
+ open_ix.append(len(out) - 1) # kept for column-faithful display)
248
+ return [tuple(x) for x in out]
249
+
250
+
251
+ def _parse_comment_tint(comment):
252
+ """Tint tuple from a `# [tint=(...)]` override comment, or None. Uses the
253
+ app's canonical override parser when importable; a literal-eval fallback
254
+ keeps the module standalone (tests)."""
255
+ try:
256
+ from meltygui.code.libcst_conversion import _parse_override_comment
257
+ parsed = _parse_override_comment(comment)
258
+ t = parsed.get("tint") if parsed else None
259
+ except ImportError:
260
+ m = _re.search(r"tint\s*=\s*(\([^)]*\))", comment)
261
+ if m is None:
262
+ return None
263
+ import ast
264
+ try:
265
+ t = ast.literal_eval(m.group(1))
266
+ except (ValueError, SyntaxError):
267
+ return None
268
+ if (isinstance(t, (tuple, list)) and 3 <= len(t) <= 4
269
+ and all(isinstance(c, (int, float)) for c in t)):
270
+ return tuple(t[:3])
271
+ return None
272
+
273
+
274
+ def _extract_line_tints(text: str, is_py: bool) -> list:
275
+ """[(start, end, tint)] 1-based inclusive spans for `# [tint=(...)]`
276
+ override comments — the editor's per-LINE tint wash, which mostly
277
+ annotates assignments (Toggles settings, class attrs), not defs. A
278
+ standalone comment washes itself through the next code line (skipping
279
+ blanks and further comments — decorator runs get the wash too, harmless);
280
+ an inline trailing comment washes its own line. Def-attached comments are
281
+ ALSO resolved by _scan_tint for the symbol table; here they just wash the
282
+ comment/def lines themselves, matching the editor."""
283
+ if not is_py:
284
+ return []
285
+ out = []
286
+ lines = text.split("\n")
287
+ i, n = 0, len(lines)
288
+ while i < n:
289
+ ln = lines[i]
290
+ s = ln.strip()
291
+ if s.startswith("#") and "[" in s:
292
+ tint = _parse_comment_tint(s)
293
+ if tint is not None:
294
+ j = i + 1
295
+ while j < n and (not lines[j].strip()
296
+ or lines[j].strip().startswith("#")):
297
+ j += 1
298
+ out.append((i + 1, j + 1 if j < n else i + 1, tint))
299
+ i = j + 1
300
+ continue
301
+ elif "#" in ln and "[" in ln:
302
+ m = _re.search(r"#.*$", ln)
303
+ tint = _parse_comment_tint(m.group(0)) if m else None
304
+ if tint is not None:
305
+ out.append((i + 1, i + 1, tint))
306
+ i += 1
307
+ return out
308
+
309
+
310
+ def _line_tint(line_tints, hit_line):
311
+ """Tint of the override-comment span containing hit_line, or None.
312
+ Spans are line-ordered; the first containing span wins (they don't
313
+ meaningfully nest)."""
314
+ for s, e, t in line_tints:
315
+ if s > hit_line:
316
+ break
317
+ if hit_line <= e:
318
+ return t
319
+ return None
320
+
321
+
322
+ def _enclosing_tint(symbols, hit_line):
323
+ """Tint of the innermost tinted definition whose block span contains
324
+ `hit_line` — the block wash the hit renders inside in the editor. Later
325
+ matching symbols are deeper (siblings are excluded by their end), so the
326
+ last match wins. None when no enclosing definition declares a tint."""
327
+ tint = None
328
+ for _name, ln, _indent, _kind, t, end, _sig in symbols:
329
+ if ln > hit_line:
330
+ break
331
+ if hit_line <= end and t is not None:
332
+ tint = t
333
+ return tint
334
+
335
+
336
+ # ── Trigram primitives ────────────────────────────────────────────────────────
337
+
338
+ def _trigrams(lowered: bytes) -> np.ndarray:
339
+ """Sorted unique uint32 trigram keys of a lowercased utf-8 buffer
340
+ (b0<<16 | b1<<8 | b2 — always < 2^24)."""
341
+ a = np.frombuffer(lowered, np.uint8)
342
+ if a.size < 3:
343
+ return np.empty(0, np.uint32)
344
+ k = ((a[:-2].astype(np.uint32) << 16)
345
+ | (a[1:-1].astype(np.uint32) << 8)
346
+ | a[2:])
347
+ return np.unique(k)
348
+
349
+
350
+ def _lower_bytes(text: str) -> bytes:
351
+ # str.lower before encode so case folding is unicode-consistent with the
352
+ # query's str lowering (bytes.lower is ASCII-only).
353
+ return text.lower().encode("utf-8", "replace")
354
+
355
+
356
+ # ── Segment: the immutable mmapped index file ─────────────────────────────────
357
+
358
+ class _Segment:
359
+ """Read-only view over one index file. Numeric sections are numpy views
360
+ straight into the mmap (nothing copied); only the meta blob (paths, sigs)
361
+ loads into RAM."""
362
+
363
+ def __init__(self, path: Path):
364
+ with open(path, "rb") as f:
365
+ self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
366
+ magic, meta_off, meta_len, n_tri, keys_off, offs_off, post_off = \
367
+ _HDR.unpack_from(self._mm, 0)
368
+ if magic != _MAGIC:
369
+ raise ValueError(f"bad index magic in {path}")
370
+ meta = pickle.loads(self._mm[meta_off:meta_off + meta_len])
371
+ self.root = meta["root"]
372
+ self.paths = meta["paths"] # rel path strs, id = path index
373
+ self.sigs = meta["sigs"] # (mtime_ns, size, pending_gen)
374
+ self.symbols = meta["symbols"] # per-file [(name, line, indent,
375
+ # kind, tint, end, sig)]
376
+ self.line_tints = meta["line_tints"] # per-file [(start, end, tint)]
377
+ self.keys = np.frombuffer(self._mm, np.uint32, int(n_tri), int(keys_off))
378
+ self.offs = np.frombuffer(self._mm, np.uint64, int(n_tri) + 1, int(offs_off))
379
+ self.post_off = int(post_off)
380
+ self.id_by_path = {p: i for i, p in enumerate(self.paths)}
381
+
382
+ def postings(self, key: int):
383
+ """uint32 file ids containing trigram `key`, or None if absent."""
384
+ i = int(np.searchsorted(self.keys, np.uint32(key)))
385
+ if i >= len(self.keys) or int(self.keys[i]) != key:
386
+ return None
387
+ a, b = int(self.offs[i]), int(self.offs[i + 1])
388
+ return np.frombuffer(self._mm, np.uint32, b - a, self.post_off + 4 * a)
389
+
390
+
391
+ def _walk_rel_files(root: str) -> list:
392
+ out = []
393
+ for dirpath, dirnames, filenames in os.walk(root):
394
+ dirnames[:] = [d for d in dirnames
395
+ if d not in _SKIP_DIRS and not d.startswith(".")]
396
+ for fn in filenames:
397
+ if os.path.splitext(fn)[1].lower() in _TEXT_EXTS:
398
+ out.append(os.path.relpath(os.path.join(dirpath, fn), root))
399
+ out.sort()
400
+ return out
401
+
402
+
403
+ def _pad8(f):
404
+ f.write(b"\0" * (-f.tell() % 8))
405
+
406
+
407
+ def _build_segment(root: str, dst: Path):
408
+ """Walk `root`, index every text file (pending text as truth), write the
409
+ segment atomically. Runs on a background thread — pure CPU/IO, no GL."""
410
+ t0 = time.perf_counter()
411
+ pend = _pending_gens()
412
+ rels = _walk_rel_files(root)
413
+ paths, sigs, symbols, line_tints = [], [], [], []
414
+ key_parts, id_parts = [], []
415
+ for rel in rels:
416
+ ap = os.path.join(root, rel)
417
+ try:
418
+ st = os.stat(ap)
419
+ except OSError:
420
+ continue
421
+ if st.st_size > _MAX_FILE_BYTES:
422
+ continue
423
+ rp = ap
424
+ try:
425
+ rp = str(Path(ap).resolve())
426
+ except OSError:
427
+ pass
428
+ gen = pend.get(rp, 0)
429
+ text = _current_text(ap) if gen else None
430
+ if text is None:
431
+ try:
432
+ with open(ap, "rb") as f:
433
+ raw = f.read()
434
+ except OSError:
435
+ continue
436
+ if b"\0" in raw[:4096]:
437
+ continue # binary despite the extension
438
+ text = raw.decode("utf-8", "replace")
439
+ fid = len(paths)
440
+ tris = _trigrams(_lower_bytes(text))
441
+ paths.append(rel)
442
+ sigs.append((st.st_mtime_ns, st.st_size, gen))
443
+ symbols.append(_extract_symbols(text, rel.endswith(".py")))
444
+ line_tints.append(_extract_line_tints(text, rel.endswith(".py")))
445
+ if tris.size:
446
+ key_parts.append(tris)
447
+ id_parts.append(np.full(tris.size, fid, np.uint32))
448
+
449
+ if key_parts:
450
+ keys = np.concatenate(key_parts)
451
+ ids = np.concatenate(id_parts)
452
+ order = np.lexsort((ids, keys)) # by key, then file id ascending
453
+ keys, post = keys[order], ids[order]
454
+ uniq, first = np.unique(keys, return_index=True)
455
+ offs = np.append(first, len(keys)).astype(np.uint64)
456
+ else:
457
+ uniq = np.empty(0, np.uint32)
458
+ offs = np.zeros(1, np.uint64)
459
+ post = np.empty(0, np.uint32)
460
+
461
+ meta = pickle.dumps({"root": root, "paths": paths, "sigs": sigs,
462
+ "symbols": symbols, "line_tints": line_tints})
463
+ tmp = dst.with_suffix(".tmp")
464
+ with open(tmp, "wb") as f:
465
+ f.write(b"\0" * _HDR.size)
466
+ _pad8(f); meta_off = f.tell(); f.write(meta)
467
+ _pad8(f); keys_off = f.tell(); f.write(uniq.tobytes())
468
+ _pad8(f); offs_off = f.tell(); f.write(offs.tobytes())
469
+ _pad8(f); post_off = f.tell(); f.write(post.tobytes())
470
+ f.seek(0)
471
+ f.write(_HDR.pack(_MAGIC, meta_off, len(meta), len(uniq),
472
+ keys_off, offs_off, post_off))
473
+ os.replace(tmp, dst)
474
+ print(f"text_index: built {len(paths)} files, {len(uniq)} trigrams, "
475
+ f"{post.size} postings in {time.perf_counter() - t0:.2f}s -> {dst}")
476
+
477
+
478
+ # ── Live state (adopted through sys, survives edit-in-place/hotswap) ──────
479
+
480
+ def _state(root: str) -> dict:
481
+ store = getattr(sys, "_lsd_text_index_state", None)
482
+ if not isinstance(store, dict):
483
+ store = sys._lsd_text_index_state = {}
484
+ st = store.get(root)
485
+ if st is not None:
486
+ # Adopted across a restart-in-place: the segment object was built by an
487
+ # earlier session and references THAT session's _Segment class, which
488
+ # pins its entire module graph. Same source → same layout → re-point it.
489
+ seg = st.get("seg")
490
+ if seg is not None and type(seg) is not _Segment:
491
+ try:
492
+ seg.__class__ = _Segment
493
+ except TypeError:
494
+ st["seg"] = None # layout changed: rebuild lazily
495
+ if st is None:
496
+ st = store[root] = {
497
+ "seg": None, # _Segment | None
498
+ "dirty": set(), # abs paths whose segment entry is stale
499
+ "extra": set(), # abs paths not in the segment (new files)
500
+ "lock": threading.Lock(),
501
+ "last_stat": 0.0,
502
+ "last_walk": 0.0,
503
+ "building": False,
504
+ }
505
+ return st
506
+
507
+
508
+ def _ensure_segment(st, root):
509
+ with st["lock"]:
510
+ if st["seg"] is not None:
511
+ return
512
+ sp = _segment_file(root)
513
+ if sp.exists():
514
+ try:
515
+ st["seg"] = _Segment(sp)
516
+ return
517
+ except Exception:
518
+ traceback.print_exc() # corrupt → rebuild below
519
+ _build_segment(root, sp) # first build: synchronous, but the
520
+ st["seg"] = _Segment(sp) # caller is already off-thread
521
+
522
+
523
+ def _sweep(st, root):
524
+ """Refresh the dirty/extra overlay from cheap signals. Stat sweep and
525
+ directory walk are TTL-throttled; the pending-gen compare is O(edited
526
+ files) and runs every time. At very large file counts the stat sweep is
527
+ the piece to move to watcher-driven marking — everything else is O(small)."""
528
+ seg = st["seg"]
529
+ now = time.monotonic()
530
+ pend = _pending_gens()
531
+ if seg is not None:
532
+ for rp, gen in pend.items():
533
+ if not _under(rp, root):
534
+ continue
535
+ fid = seg.id_by_path.get(os.path.relpath(rp, root))
536
+ if fid is None:
537
+ st["extra"].add(rp)
538
+ elif seg.sigs[fid][2] != gen:
539
+ st["dirty"].add(rp)
540
+ if now - st["last_stat"] > _STAT_TTL:
541
+ st["last_stat"] = now
542
+ for fid, rel in enumerate(seg.paths):
543
+ ap = os.path.join(root, rel)
544
+ sig = seg.sigs[fid]
545
+ try:
546
+ s = os.stat(ap)
547
+ if (s.st_mtime_ns, s.st_size) != (sig[0], sig[1]):
548
+ st["dirty"].add(ap)
549
+ except OSError:
550
+ st["dirty"].add(ap) # deleted: scan yields nothing
551
+ if now - st["last_walk"] > _WALK_TTL:
552
+ st["last_walk"] = now
553
+ known = seg.id_by_path
554
+ for rel in _walk_rel_files(root):
555
+ if rel not in known:
556
+ st["extra"].add(os.path.join(root, rel))
557
+
558
+
559
+ def _maybe_rebuild(st, root):
560
+ if len(st["dirty"]) + len(st["extra"]) <= _REBUILD_DIRTY or st["building"]:
561
+ return
562
+ st["building"] = True
563
+
564
+ def _run():
565
+ try:
566
+ sp = _segment_file(root)
567
+ _build_segment(root, sp)
568
+ with st["lock"]:
569
+ st["seg"] = _Segment(sp) # old mmap freed when views drop
570
+ st["dirty"].clear()
571
+ st["extra"].clear()
572
+ st["last_stat"] = time.monotonic()
573
+ except Exception:
574
+ traceback.print_exc()
575
+ finally:
576
+ st["building"] = False
577
+
578
+ threading.Thread(target=_run, daemon=True, name="text-index-rebuild").start()
579
+
580
+
581
+ # ── Search ─────────────────────────────────────────────────────────────────────
582
+
583
+ def _candidates(seg, qb: bytes):
584
+ """File ids whose trigram set covers every query trigram (superset of the
585
+ true matches — verification prunes). None postings for any trigram means
586
+ no segment file can contain the query."""
587
+ tris = {qb[i:i + 3] for i in range(len(qb) - 2)}
588
+ lists = []
589
+ for t in tris:
590
+ p = seg.postings((t[0] << 16) | (t[1] << 8) | t[2])
591
+ if p is None:
592
+ return np.empty(0, np.uint32)
593
+ lists.append(p)
594
+ lists.sort(key=len)
595
+ cand = lists[0]
596
+ for p in lists[1:]:
597
+ if not cand.size:
598
+ break
599
+ cand = np.intersect1d(cand, p, assume_unique=True)
600
+ return cand
601
+
602
+
603
+ def _verify(abs_path: str, text: str, ql: str, per_file=_PER_FILE_CAP):
604
+ """(1-based line, RAW line text — indent kept, so renderers can map file
605
+ columns) for each case-insensitive occurrence,
606
+ capped. Offsets are found on the lowered text and snippets cut from the
607
+ original at the same indices; the rare unicode case where lower() changes
608
+ the string's length (e.g. İ) would skew them, so such files fall back to
609
+ lowercased snippets — offsets then index the buffer they came from."""
610
+ tl = text.lower()
611
+ if len(tl) != len(text):
612
+ text = tl
613
+ out = []
614
+ pos = tl.find(ql)
615
+ while pos != -1 and len(out) < per_file:
616
+ line = tl.count("\n", 0, pos) + 1
617
+ ls = text.rfind("\n", 0, pos) + 1
618
+ le = text.find("\n", pos)
619
+ out.append((line, text[ls: le if le != -1 else len(text)].rstrip()))
620
+ nl = tl.find("\n", pos) # one hit per line reads better
621
+ pos = tl.find(ql, nl + 1) if nl != -1 else -1
622
+ return out
623
+
624
+
625
+ def _hit(kind, root, rel, line, text, tint, scope=None):
626
+ return {"kind": kind, "path": os.path.join(root, rel), "rel": rel,
627
+ "line": line, "text": text, "tint": tint, "scope": scope}
628
+
629
+
630
+ def _enclosing_scope(symbols, hit_line):
631
+ """Qualname chain (tuple of def/class names, outermost first) of the
632
+ definitions whose block span contains `hit_line` — the scope a content
633
+ hit sits in ("Toggles", "TextEditor"); () at module level. Same
634
+ containment walk as _enclosing_tint."""
635
+ chain = []
636
+ for name, ln, indent, _kind, _t, end, _sig in symbols:
637
+ if ln > hit_line:
638
+ break
639
+ if hit_line <= end:
640
+ while chain and chain[-1][1] >= indent:
641
+ chain.pop()
642
+ chain.append((name, indent))
643
+ return tuple(n for n, _i in chain)
644
+
645
+
646
+ def candidate_paths(query: str, exts=(".py",), root=None) -> list:
647
+ """Absolute paths whose CURRENT text may contain `query` (case-
648
+ insensitive): every overlay file (dirty / new — their segment entry is
649
+ stale, so they are always candidates) plus the segment files whose
650
+ trigram postings cover the query. A SUPERSET of the true matches —
651
+ callers verify against the text. `exts` filters by extension. The
652
+ symbol roster's reverse lookup (usages of a definition) is built on
653
+ this: O(results) posting intersection instead of a scan of every file.
654
+ Call from a background thread on first use: it builds the index."""
655
+ ql = query.lower()
656
+ qb = ql.encode("utf-8", "replace")
657
+ root = _resolve_root(root)
658
+ st = _state(root)
659
+ _ensure_segment(st, root)
660
+ _sweep(st, root)
661
+ _maybe_rebuild(st, root)
662
+ seg = st["seg"]
663
+ out = []
664
+ seen = set()
665
+ for ap in sorted(st["dirty"] | st["extra"]):
666
+ if os.path.splitext(ap)[1].lower() in exts and ap not in seen:
667
+ seen.add(ap)
668
+ out.append(ap)
669
+ if seg is not None:
670
+ if len(qb) < 3:
671
+ fids = range(len(seg.paths)) # too short to trigram: everything
672
+ else:
673
+ fids = (int(f) for f in _candidates(seg, qb))
674
+ for fid in fids:
675
+ rel = seg.paths[fid]
676
+ if os.path.splitext(rel)[1].lower() not in exts:
677
+ continue
678
+ ap = os.path.join(root, rel)
679
+ if ap not in seen:
680
+ seen.add(ap)
681
+ out.append(ap)
682
+ return out
683
+
684
+
685
+ def _park_ui():
686
+ """Park this (background) thread while the render thread is mid-frame —
687
+ libcst_conversion._park_while_frame, reached through sys.modules so
688
+ text_index gains no import edge on the conversion stack (and offline
689
+ callers that never loaded it just don't park). Why: search() is a CPU
690
+ chunk (~140 ms warm for a common word — a lower()+scan per candidate
691
+ file), and a CPU-bound background thread GIL-convoys every frame it
692
+ overlaps; parking at file boundaries keeps keystroke frames smooth."""
693
+ m = sys.modules.get("meltygui.code.libcst_conversion")
694
+ if m is not None:
695
+ park = getattr(m, "_park_while_frame", None)
696
+ if park is not None:
697
+ park()
698
+
699
+
700
+ def search(query: str, limit=200, per_file=_PER_FILE_CAP, cancelled=None, root=None):
701
+ """Case-insensitive search over `root` (default: the src root — see
702
+ _search_root; a meltygui app passes its own project roots, one call per
703
+ root), pending edits included.
704
+ Returns hit dicts {kind, path, rel, line, text, tint} in three kinds,
705
+ listed in this order:
706
+ file — the file's NAME matches (line None, text = basename)
707
+ symbol — a class/def NAME matches (text = the def line, tint = the
708
+ definition's own explicit tint)
709
+ line — full-text content match (text = the line, tint = the innermost
710
+ enclosing tinted definition's — the editor's block wash,
711
+ scope = the enclosing def/class qualname chain as a tuple)
712
+ Tints are index-resolved and None when no source tint applies (the UI
713
+ falls back to FileMeta / category tints). Overlay (dirty/new) files are
714
+ served live and shadow their stale segment entries. `per_file` caps the
715
+ content hits reported per file (default _PER_FILE_CAP). Call from a
716
+ background thread: the first call builds the index.
717
+
718
+ `cancelled` (a nullary callable) makes the scan ABANDONABLE: checked at
719
+ file boundaries, and a True return abandons the search and returns None
720
+ (not a partial list — the caller must land nothing). The global-search
721
+ text pass hands its generation staleness here so a new keystroke stops
722
+ a now-pointless scan within one file instead of finishing ~140 ms of
723
+ dead work that would GIL-convoy the keystroke's frame."""
724
+ ql = query.lower()
725
+ qb = ql.encode("utf-8", "replace")
726
+ if len(qb) < 3:
727
+ return []
728
+ root = _resolve_root(root)
729
+ st = _state(root)
730
+ _ensure_segment(st, root)
731
+ _sweep(st, root)
732
+ _maybe_rebuild(st, root)
733
+
734
+ seg = st["seg"]
735
+ overlay = sorted(st["dirty"] | st["extra"])
736
+ overlay_rel = {os.path.relpath(p, root) for p in overlay}
737
+ file_hits, sym_hits, line_hits = [], [], []
738
+
739
+ # ── file-name hits: the whole indexed universe + unseen extras ──
740
+ universe = list(seg.paths) if seg is not None else []
741
+ universe += [r for r in (os.path.relpath(p, root) for p in st["extra"])
742
+ if seg is None or r not in seg.id_by_path]
743
+ for rel in universe:
744
+ if ql in os.path.basename(rel).lower():
745
+ file_hits.append(_hit("file", root, rel, None,
746
+ os.path.basename(rel), None))
747
+ if len(file_hits) >= _FILE_HIT_CAP:
748
+ break
749
+
750
+ def _sym_match(rel, syms):
751
+ for name, ln, _ind, _kind, tint, _end, sig in syms:
752
+ if ql in name.lower():
753
+ sym_hits.append((name.lower(), _hit("symbol", root, rel, ln,
754
+ sig, tint)))
755
+
756
+ # ── overlay files: live symbol table + content scan (freshest wins) ──
757
+ scanned = {}
758
+ for ap in overlay:
759
+ if cancelled is not None and cancelled():
760
+ return None
761
+ _park_ui()
762
+ text = _current_text(ap)
763
+ rel = os.path.relpath(ap, root)
764
+ scanned[ap] = None
765
+ if not text:
766
+ continue
767
+ syms = _extract_symbols(text, rel.endswith(".py"))
768
+ ltints = _extract_line_tints(text, rel.endswith(".py"))
769
+ _sym_match(rel, syms)
770
+ if len(line_hits) < limit:
771
+ for line, snippet in _verify(ap, text, ql, per_file):
772
+ line_hits.append(_hit("line", root, rel, line, snippet,
773
+ _line_tint(ltints, line)
774
+ or _enclosing_tint(syms, line),
775
+ _enclosing_scope(syms, line)))
776
+
777
+ # ── segment symbol tables (overlay files shadowed above) ──
778
+ if seg is not None:
779
+ for i, (rel, syms) in enumerate(zip(seg.paths, seg.symbols)):
780
+ if i % 64 == 0:
781
+ if cancelled is not None and cancelled():
782
+ return None
783
+ _park_ui()
784
+ if len(sym_hits) >= _SYMBOL_HIT_CAP * 10:
785
+ break # raw pool; ranked + trimmed below
786
+ if rel not in overlay_rel:
787
+ _sym_match(rel, syms)
788
+ # Name-prefix matches ahead of nameested ones (the scorer's prefix flag),
789
+ # then shorter names - the tightest match tops the list.
790
+ sym_hits.sort(key=lambda t: (not t[0].startswith(ql), len(t[0]), t[0]))
791
+ sym_hits = [h for _n, h in sym_hits[:_SYMBOL_HIT_CAP]]
792
+
793
+ # ── segment content candidates ──
794
+ if seg is not None and len(line_hits) < limit:
795
+ for fid in _candidates(seg, qb)[:_CAND_CAP]:
796
+ if len(line_hits) >= limit:
797
+ break
798
+ # Per-file boundary: the lower()+scan below is the search's cost
799
+ # center, so this is where cancellation and frame-parking bite.
800
+ if cancelled is not None and cancelled():
801
+ return None
802
+ _park_ui()
803
+ fid = int(fid)
804
+ ap = os.path.join(root, seg.paths[fid])
805
+ if ap in scanned:
806
+ continue
807
+ text = _current_text(ap)
808
+ if not text:
809
+ continue
810
+ for line, snippet in _verify(ap, text, ql, per_file):
811
+ line_hits.append(_hit("line", root, seg.paths[fid], line,
812
+ snippet,
813
+ _line_tint(seg.line_tints[fid], line)
814
+ or _enclosing_tint(seg.symbols[fid], line),
815
+ _enclosing_scope(seg.symbols[fid], line)))
816
+ return (file_hits + sym_hits + line_hits)[:limit]