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,1343 @@
1
+ import os
2
+ import sys
3
+ import re
4
+ import io
5
+ import shutil
6
+ import subprocess
7
+ import pprint
8
+ import inspect
9
+ import threading
10
+ import traceback
11
+ import json
12
+ import linecache
13
+ import time
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+
17
+ import meltygui.core.windowing.window_api as glfw
18
+
19
+ from meltygui.core.runtime.toggles import Toggles
20
+ from meltygui.core.rendering.core_decoration import Core
21
+
22
+ # ── Module roots for user code detection ─────────────────
23
+ _MODULE_ROOTS = ["src/lsd/"]
24
+
25
+ # Sane bounds on the UI scale (Toggles.UIScale). The scale multiplies font
26
+ # atlas sizes, so a stray number from a live edit is an expensive error - a
27
+ # huge factor bakes a giant atlas, a tiny one rasterizes unreadable fonts.
28
+ # A value outside these bounds is treated as an accident, NOT an intent: the
29
+ # guard falls back to 1.0 rather than pinning the UI at an extreme.
30
+ UI_SCALE_MIN = 0.5
31
+ UI_SCALE_MAX = 3.0
32
+
33
+
34
+ def clamp_ui_scale(value) -> float:
35
+ """Sanitize a candidate ui scale: a float within
36
+ [UI_SCALE_MIN, UI_SCALE_MAX] passes through; anything crazy — out of
37
+ bounds, None, 0, NaN, non-numeric — is interpreted as 1.0 (a wild value
38
+ is a live-edit artifact or typo, and rebuilding the font atlas at 20x
39
+ would only amplify the accident). The single guard every ui-scale
40
+ consumer goes through."""
41
+ try:
42
+ v = float(value)
43
+ except (TypeError, ValueError):
44
+ return 1.0
45
+ if v != v or v < UI_SCALE_MIN or v > UI_SCALE_MAX:
46
+ return 1.0
47
+ return v
48
+
49
+
50
+ # ── Native cursor size and theme ───────────────────────────
51
+
52
+ # Full path on purpose: a bare name makes subprocess fall back to fork() of
53
+ # the CUDA/GL/torch address space (see claude_terminals) - posix_spawn needs
54
+ # an absolute path and close_fds=False.
55
+ _GSETTINGS = shutil.which("gsettings") or "/usr/bin/gsettings"
56
+ _DESKTOP_INTERFACE_SCHEMA = "org.gnome.desktop.interface"
57
+
58
+
59
+ def _gsettings_get(key):
60
+ """`gsettings get org.gnome.desktop.interface <key>` as its raw stdout
61
+ (stripped), None if the tool is missing, fails or times out."""
62
+ if not os.path.exists(_GSETTINGS):
63
+ return None
64
+ try:
65
+ result = subprocess.run([_GSETTINGS, "get", _DESKTOP_INTERFACE_SCHEMA, key],
66
+ capture_output=True, text=True, timeout=3,
67
+ close_fds=False)
68
+ except (OSError, subprocess.SubprocessError):
69
+ return None
70
+ if result.returncode != 0:
71
+ return None
72
+ return result.stdout.strip()
73
+
74
+
75
+ def export_desktop_cursor_env():
76
+ """Make GLFW's native cursors the DESKTOP's size and theme.
77
+
78
+ On Wayland GLFW sizes every cursor it shows — the arrow it pushes on
79
+ pointer-enter as much as the shapes gl_gui/mouse_cursor.py sets — from
80
+ XCURSOR_SIZE / XCURSOR_THEME, read ONCE by the first glfw.init() in the
81
+ process (wl_init.c loadCursorTheme: 16 px when unset). GNOME Wayland
82
+ sessions export neither, so the studio's cursors came out at the theme
83
+ image nearest 16 px (22 px Bibata) while the desktop draws 32. This reads
84
+ GNOME's own settings and exports them; anything already in the
85
+ environment wins (a user's export is an intent). Must run before the
86
+ FIRST glfw.init() of the process: GLFW init is process-wide, the
87
+ launcher's init_gui does it and the studio's later init is a no-op.
88
+ Returns {var: value} of what it exported (empty = nothing to do).
89
+ """
90
+ exported = {}
91
+ if not os.environ.get("WAYLAND_DISPLAY"):
92
+ return exported # X11: GLFW asks Xcursor, which GNOME configures itself
93
+ if not os.environ.get("XCURSOR_SIZE"):
94
+ raw = _gsettings_get("cursor-size")
95
+ try:
96
+ size = int(raw) if raw is not None else 0
97
+ except ValueError:
98
+ size = 0
99
+ if size > 0:
100
+ os.environ["XCURSOR_SIZE"] = str(size)
101
+ exported["XCURSOR_SIZE"] = str(size)
102
+ if not os.environ.get("XCURSOR_THEME"):
103
+ raw = _gsettings_get("cursor-theme")
104
+ theme = raw.strip("'\"") if raw else ""
105
+ if theme:
106
+ os.environ["XCURSOR_THEME"] = theme
107
+ exported["XCURSOR_THEME"] = theme
108
+ return exported
109
+
110
+
111
+ def apply_wayland_frame_hint():
112
+ """Toggles.Melty.wayland_native_frame → tell GLFW to skip libdecor.
113
+
114
+ GNOME has no server-side decorations, so on native Wayland GLFW gives
115
+ the window to libdecor, whose cairo plugin repaints the title bar and
116
+ shadow on the CPU for every resize configure (tens of ms a step at the
117
+ studio's size — the 2 fps OS-window resize). The WAYLAND_DISABLE_LIBDECOR
118
+ init hint makes GLFW draw its own fallback frame instead (caption strip
119
+ + borders, compositor-driven move/resize, <1 ms a step, no buttons —
120
+ titlebar.py draws those). Init hints only count before the FIRST
121
+ glfw.init() of the process, the launcher's, so this runs beside
122
+ export_desktop_cursor_env at both init sites; the outcome is recorded
123
+ process-wide (sys._lsd_wayland_libdecor_disabled) so
124
+ titlebar.backend_supported reads what the process actually got, not the
125
+ live toggle. Returns True when the hint was applied by this call."""
126
+ if getattr(sys, "_lsd_wayland_libdecor_disabled", None) is not None:
127
+ return False # decided at the first init - later inits are no-ops
128
+ applied = False
129
+ if os.environ.get("WAYLAND_DISPLAY") and Toggles.Melty.wayland_native_frame:
130
+ try:
131
+ glfw.init_hint(glfw.WAYLAND_LIBDECOR, glfw.WAYLAND_DISABLE_LIBDECOR)
132
+ applied = True
133
+ except AttributeError:
134
+ pass # pre-3.4 pyglfw: no hint, libdecor stays
135
+ sys._lsd_wayland_libdecor_disabled = applied
136
+ return applied
137
+
138
+
139
+ def wayland_native_frame_active():
140
+ """True when this process's GLFW runs Wayland windows without libdecor
141
+ (apply_wayland_frame_hint took effect at the first init)."""
142
+ return bool(getattr(sys, "_lsd_wayland_libdecor_disabled", False))
143
+
144
+
145
+ def _is_user_code(filepath):
146
+ """Check if a file is inside the user's module."""
147
+ rel = _rel_path(filepath)
148
+ return any(root in rel for root in _MODULE_ROOTS)
149
+
150
+
151
+ # ── Syntax highlighting (IntelliJ Darcula) ───────────────
152
+ try:
153
+ from pygments import highlight as _pyg_highlight
154
+ from pygments.lexers import PythonLexer
155
+ from pygments.formatters import TerminalTrueColorFormatter
156
+ from pygments.style import Style
157
+ from pygments.token import (
158
+ Token, Keyword, Name, Comment, String, Number,
159
+ Operator, Punctuation, Literal, Generic, Error
160
+ )
161
+
162
+
163
+ class DarculaIntelliJ(Style):
164
+ background_color = "#2b2b2b"
165
+ styles = {
166
+ Token: "#a9b7c6",
167
+ Comment: "italic #808080",
168
+ Comment.Preproc: "#808080",
169
+ Keyword: "#cc7832",
170
+ Keyword.Constant: "#cc7832",
171
+ Keyword.Namespace: "#cc7832",
172
+ Keyword.Type: "#cc7832",
173
+ Name: "#a9b7c6",
174
+ Name.Builtin: "#8888c6",
175
+ Name.Builtin.Pseudo: "#94558d",
176
+ Name.Function: "#ffc66d",
177
+ Name.Function.Magic: "#ffc66d",
178
+ Name.Class: "#a9b7c6",
179
+ Name.Decorator: "#bbb529",
180
+ Name.Exception: "#a9b7c6",
181
+ Name.Variable: "#a9b7c6",
182
+ Name.Attribute: "#9876aa",
183
+ Name.Tag: "#e8bf6a",
184
+ String: "#6a8759",
185
+ String.Doc: "italic #629755",
186
+ String.Escape: "#cc7832",
187
+ String.Interpol: "#cc7832",
188
+ String.Regex: "#6a8759",
189
+ Number: "#6897bb",
190
+ Number.Float: "#6897bb",
191
+ Number.Integer: "#6897bb",
192
+ Operator: "#a9b7c6",
193
+ Operator.Word: "#cc7832",
194
+ Punctuation: "#a9b7c6",
195
+ Literal: "#6a8759",
196
+ Generic.Deleted: "#ff5555",
197
+ Generic.Inserted: "#6a8759",
198
+ Generic.Error: "#ff5555",
199
+ Generic.Emph: "italic",
200
+ Generic.Strong: "bold",
201
+ Error: "#ff5555",
202
+ }
203
+
204
+
205
+ _pygments_available = True
206
+ _python_lexer = PythonLexer()
207
+ _value_lexer = PythonLexer(stripnl=True, stripall=True, ensurenl=False)
208
+ _terminal_formatter = TerminalTrueColorFormatter(style=DarculaIntelliJ)
209
+ except ImportError:
210
+ _pygments_available = False
211
+
212
+ # ── ANSI codes ───────────────────────────────────────────
213
+ _BOLD = "\033[1m"
214
+ _DIM = "\033[2m"
215
+ _RESET = "\033[0m"
216
+ _CYAN = "\033[36m"
217
+ _YELLOW = "\033[33m"
218
+ _GREEN = "\033[32m"
219
+ _MAGENTA = "\033[35m"
220
+ _BLUE = "\033[34m"
221
+ _WHITE = "\033[97m"
222
+ _RED = "\033[31m"
223
+ _BLACK = "\033[30m"
224
+ _BG_DARK = "\033[48;2;22;22;22m"
225
+ _NO_BG = "\033[49m"
226
+ _RED_FG = "\033[38;2;255;85;85m"
227
+ _TABLE_LINE = "\033[38;2;60;60;60m"
228
+ _TABLE_LINE_RED = "\033[38;2;100;40;40m"
229
+
230
+ _BG_COLORS = [
231
+ "\033[41m", "\033[42m", "\033[43m",
232
+ "\033[44m", "\033[45m", "\033[46m",
233
+ ]
234
+
235
+ _IDE = "intellij"
236
+
237
+ _IDE_SCHEMES = {
238
+ "idea": "idea://open?file={path}&line={line}",
239
+ "pycharm": "pycharm://open?file={path}&line={line}",
240
+ "fleet": "fleet://open?file={path}&line={line}",
241
+ "goland": "goland://open?file={path}&line={line}",
242
+ "webstorm": "webstorm://open?file={path}&line={line}",
243
+ "vscode": "vscode://file/{path}:{line}",
244
+ "traceback": None,
245
+ "intellij": None,
246
+ }
247
+
248
+ _print_lock = threading.Lock()
249
+ _job_counter = 0
250
+ _job_counter_lock = threading.Lock()
251
+ _ANSI_RE = re.compile(r'\033\[[0-9;]*m|\033\]8;[^\033]*\033\\')
252
+
253
+
254
+ def _next_job_color():
255
+ global _job_counter
256
+ with _job_counter_lock:
257
+ color = _BG_COLORS[_job_counter % len(_BG_COLORS)]
258
+ _job_counter += 1
259
+ return color
260
+
261
+
262
+ def _visible_len(s):
263
+ """String length ignoring ANSI escape codes."""
264
+ return len(_ANSI_RE.sub('', s))
265
+
266
+
267
+ def _pad(s, width):
268
+ """Pad a string with ANSI codes to a visible width."""
269
+ return s + " " * max(0, width - _visible_len(s))
270
+
271
+
272
+ # ── Caller location ─────────────────────────────────────
273
+
274
+ def _find_caller():
275
+ """
276
+ Walk the stack to find where print_stack_trace was called.
277
+ Returns (rel_path, lineno) or None.
278
+ """
279
+ my_file = os.path.abspath(__file__)
280
+ for info in inspect.stack():
281
+ filename = info[1]
282
+ funcname = info[3]
283
+ lineno = info[2]
284
+ if os.path.abspath(filename) == my_file:
285
+ continue
286
+ if funcname in ('__exit__', 'flush', 'write_section', 'write_header',
287
+ 'write_footer', '_task'):
288
+ continue
289
+ return (_rel_path(filename), lineno)
290
+ return None
291
+
292
+
293
+ def _caller_link():
294
+ """Build a clickable 'edit watches' link to the call site."""
295
+ caller = _find_caller()
296
+ if not caller:
297
+ return ""
298
+ return f" {_DIM}watches \u2192 File \"{_BLUE}{caller[0]}{_DIM}\", line {caller[1]}{_RESET}"
299
+
300
+
301
+ # ── Function argument extraction ─────────────────────────
302
+
303
+ def _get_func_args(filename, lineno, funcname, local_vars):
304
+ """
305
+ Get function argument names from the source.
306
+ Searches backward from current line for the def statement
307
+ and parses argument names from the signature.
308
+ """
309
+ try:
310
+ lines = linecache.getlines(filename)
311
+ for j in range(min(lineno - 1, len(lines) - 1), max(lineno - 50, -1), -1):
312
+ line = lines[j].strip()
313
+ if line.startswith(f"def {funcname}(") or line.startswith(f"def {funcname} ("):
314
+ # Gather full signature if it spans multiple lines
315
+ sig = line
316
+ k = j + 1
317
+ while ')' not in sig and k < len(lines):
318
+ sig += " " + lines[k].strip()
319
+ k += 1
320
+ # Parse arg names from signature
321
+ match = re.search(r'def\s+\w+\s*\(([^)]*)\)', sig)
322
+ if match:
323
+ params = match.group(1)
324
+ args = []
325
+ for param in params.split(','):
326
+ param = param.strip()
327
+ if not param or param == '/':
328
+ continue
329
+ name = re.match(r'\*{0,2}\s*(\w+)', param)
330
+ if name:
331
+ n = name.group(1)
332
+ if n not in ('self', 'cls') and n in local_vars:
333
+ args.append(n)
334
+ return args
335
+ except Exception:
336
+ pass
337
+ return []
338
+
339
+
340
+ # ── Syntax highlighting ──────────────────────────────────
341
+
342
+ def _highlight(code, lineno=None):
343
+ """Syntax-highlight a line of Python with editor-style background and line number."""
344
+ if _pygments_available:
345
+ colored = _pyg_highlight(code, _python_lexer, _terminal_formatter).rstrip('\n')
346
+ colored = _color_kwargs(colored, code, bg=_NO_BG)
347
+ else:
348
+ colored = f"{_YELLOW}{code}{_RESET}"
349
+
350
+ if lineno is not None:
351
+ gutter = f"{_NO_BG}{_DIM} {lineno:>4} {_RESET}"
352
+ else:
353
+ gutter = ""
354
+
355
+ visible = len(code)
356
+ pad_width = max(80 - visible, 4)
357
+ return f"{gutter}{_NO_BG} {colored}{' ' * pad_width}{_RESET}"
358
+
359
+
360
+ def _highlight_inline(code):
361
+ """Syntax-highlight a short code snippet without background or gutter."""
362
+ if not _pygments_available:
363
+ return f"{_GREEN}{code}{_RESET}"
364
+ result = _pyg_highlight(code, _python_lexer, _terminal_formatter).rstrip('\n')
365
+ return _color_kwargs(result, code)
366
+
367
+
368
+ def _color_kwargs(highlighted, original, bg=None):
369
+ """Post-process to color keyword argument names red."""
370
+ restore = f"{_RESET}{bg}" if bg else _RESET
371
+ for match in re.finditer(r'(\b\w+)(?=\s*=[^=])', original):
372
+ name = match.group(1)
373
+ if name in ('if', 'else', 'elif', 'return', 'yield', 'not',
374
+ 'and', 'or', 'in', 'is', 'lambda', 'True', 'False', 'None'):
375
+ continue
376
+ highlighted = re.sub(
377
+ rf'(?<!\033\[38;2;255;85;85m)(\033\[[\d;]*m)*({re.escape(name)})(\033\[[\d;]*m)*(?=\s*=[^=])',
378
+ rf'\1{_RED_FG}{name}{restore}\3',
379
+ highlighted,
380
+ count=1
381
+ )
382
+ return highlighted
383
+
384
+
385
+ # ── Rich table rendering ─────────────────────────────────
386
+
387
+ def _rich():
388
+ """(Table, Text, Console) from rich, or None. Imported on first use: rich
389
+ costs ~10 ms and only the watch-table printer needs it."""
390
+ try:
391
+ from rich.table import Table
392
+ from rich.text import Text
393
+ from rich.console import Console
394
+ except ImportError:
395
+ return None
396
+ return Table, Text, Console
397
+
398
+
399
+ def _render_watch_table(file_line, code_line, watch_rows, error=False):
400
+ """
401
+ Render a frame with watches using rich table.
402
+ Only called when watch_rows is non-empty.
403
+ """
404
+ rich = _rich()
405
+ if rich is None:
406
+ return _render_frame_simple(file_line, code_line, watch_rows)
407
+ RichTable, RichText, RichConsole = rich
408
+
409
+ border_style = "rgb(100,40,40)" if error else "rgb(50,50,55)"
410
+
411
+ if error:
412
+ row_a = "on rgb(45,30,30)"
413
+ row_b = "on rgb(50,35,35)"
414
+ name_col = "on rgb(55,35,35)"
415
+ else:
416
+ row_a = "on rgb(19,19,19)"
417
+ row_b = "on rgb(23,23,23)"
418
+ name_col = "on rgb(28,28,28)"
419
+
420
+ table = RichTable(
421
+ show_header=False,
422
+ show_edge=False,
423
+ show_lines=False,
424
+ border_style=border_style,
425
+ pad_edge=True,
426
+ padding=(0, 1),
427
+ expand=False,
428
+ row_styles=[row_a, row_b],
429
+ )
430
+
431
+ table.add_column(overflow="ellipsis", max_width=40, no_wrap=True, style=name_col)
432
+ table.add_column(overflow="ellipsis", max_width=12, no_wrap=False)
433
+ table.add_column(overflow="ellipsis", max_width=60, no_wrap=False)
434
+ table.add_column(overflow="ellipsis", max_width=120, no_wrap=True)
435
+
436
+ for name, typ, val, link in watch_rows:
437
+ table.add_row(
438
+ RichText.from_ansi(name),
439
+ RichText.from_ansi(typ),
440
+ RichText.from_ansi(val),
441
+ RichText.from_ansi(link),
442
+ )
443
+
444
+ table_buf = io.StringIO()
445
+ console = RichConsole(
446
+ file=table_buf, highlight=False, markup=False,
447
+ width=200, force_terminal=True
448
+ )
449
+ console.print(table, end="")
450
+ watch_block = table_buf.getvalue()
451
+
452
+ buf = []
453
+ buf.append(f" {file_line}")
454
+ if code_line:
455
+ buf.append(f" {code_line}")
456
+ for line in watch_block.rstrip('\n').split('\n'):
457
+ buf.append(f" {line}")
458
+
459
+ return "\n".join(buf) + "\n"
460
+
461
+
462
+ def _render_frame_simple(file_line, code_line, watch_rows):
463
+ """Fallback renderer without rich."""
464
+ buf = []
465
+ buf.append(f" {file_line}")
466
+ if watch_rows:
467
+ for name, typ, val, link in watch_rows:
468
+ buf.append(f" {name} {typ} {val} {link}")
469
+ if code_line:
470
+ buf.append(f" {code_line}")
471
+ return "\n".join(buf) + "\n"
472
+
473
+
474
+ # ── Trace group ──────────────────────────────────────────
475
+
476
+ class TraceGroup:
477
+ """Buffers multiple print_stack_trace calls and flushes atomically."""
478
+ bar_size_outer = 58
479
+ bar_size_inner = 30
480
+
481
+ def __init__(self, label, color, **meta):
482
+ self.buf = io.StringIO()
483
+ self.label = label
484
+ self.color = color
485
+ self.meta = meta
486
+
487
+ def _bar(self, text="", size=None):
488
+ size = size or self.bar_size_inner
489
+ code = "\u2500"
490
+
491
+ if text:
492
+ pad = size - len(text) - 4
493
+ return f"{self.color}{_BLACK}{_BOLD} \u258c {text} {code * max(pad, 0)} {_RESET}"
494
+ return f"{self.color}{_BLACK}{_BOLD} {code * size} {_RESET}"
495
+
496
+ def write_header(self):
497
+ self.buf.write(f"\n{self._bar(self.label, size=self.bar_size_outer)}\n")
498
+ if self.meta:
499
+ meta = " ".join(f"{k}={v}" for k, v in self.meta.items())
500
+ self.buf.write(f"{self.color} {_RESET}{_DIM} {meta}{_RESET}\n")
501
+
502
+ def write_section(self, name):
503
+ self.buf.write(f"{self._bar(name)}\n")
504
+
505
+ def write_footer(self):
506
+ self.buf.write(f"{self._bar(size=self.bar_size_outer)}\n\n")
507
+
508
+ def flush(self, dest=None):
509
+ dest = dest or sys.stdout
510
+ with _print_lock:
511
+ dest.write(self.buf.getvalue())
512
+ dest.flush()
513
+
514
+
515
+ @contextmanager
516
+ def trace_group(label, **meta):
517
+ g = TraceGroup(label, _next_job_color(), **meta)
518
+ g.write_header()
519
+ try:
520
+ yield g
521
+ finally:
522
+ g.write_footer()
523
+ g.flush()
524
+
525
+
526
+ # ── Function resolution for watch expressions ────────────
527
+
528
+ def _resolve_func(name):
529
+ import builtins
530
+ if hasattr(builtins, name):
531
+ return getattr(builtins, name)
532
+ if "." in name:
533
+ parts = name.split(".")
534
+ for i in range(len(parts) - 1, 0, -1):
535
+ mod_path = ".".join(parts[:i])
536
+ attr_path = parts[i:]
537
+ try:
538
+ import importlib
539
+ obj = importlib.import_module(mod_path)
540
+ for attr in attr_path:
541
+ obj = getattr(obj, attr)
542
+ return obj
543
+ except (ImportError, AttributeError):
544
+ continue
545
+ for mod in sys.modules.values():
546
+ if mod and hasattr(mod, name):
547
+ return getattr(mod, name)
548
+ return None
549
+
550
+
551
+ def _parse_watch(expr):
552
+ funcs = []
553
+ while True:
554
+ match = re.match(r'^([\w.]+)\((.+)\)$', expr)
555
+ if match:
556
+ func_name = match.group(1)
557
+ if _resolve_func(func_name) is not None:
558
+ funcs.append(func_name)
559
+ expr = match.group(2)
560
+ continue
561
+ break
562
+ return funcs, expr
563
+
564
+
565
+ # ── Watch resolution helper ──────────────────────────────
566
+
567
+ def _resolve_watch(expr, filename, lineno, local_vars,
568
+ max_str_len, max_items, max_depth, max_output):
569
+ """
570
+ Resolve a single watch expression into a table row tuple,
571
+ or return None if the expression can't be resolved.
572
+ """
573
+ funcs, path = _parse_watch(expr)
574
+ root = _get_root_name(path)
575
+ if root not in local_vars:
576
+ return None
577
+ success, value = _resolve_path(path, local_vars)
578
+ if not success:
579
+ return None
580
+ try:
581
+ for func_name in reversed(funcs):
582
+ value = _resolve_func(func_name)(value)
583
+ except Exception as ex:
584
+ value = f"<{func_name}() raised {type(ex).__name__}: {ex}>"
585
+
586
+ display_val = _truncate(value, max_str_len, max_items, max_depth)
587
+ formatted_value = pprint.pformat(display_val, width=50)
588
+ if max_output and len(formatted_value) > max_output:
589
+ cut = formatted_value[:max_output].rfind('\n')
590
+ if cut == -1:
591
+ cut = max_output
592
+ formatted_value = formatted_value[:cut] + f"\u2026({len(formatted_value)}ch)"
593
+
594
+ if _pygments_available:
595
+ formatted_value = _pyg_highlight(formatted_value, _python_lexer, _terminal_formatter).rstrip('\n')
596
+
597
+ name_cell = _highlight_inline(expr)
598
+ type_cell = f"{_DIM}{type(value).__name__}{_RESET}"
599
+ value_cell = f"{_MAGENTA}{formatted_value}{_RESET}"
600
+ def_line = _find_assignment(filename, lineno, root)
601
+ link_cell = _make_link(filename, def_line)
602
+
603
+ return (name_cell, type_cell, value_cell, link_cell)
604
+
605
+
606
+ # ── Main entry point ─────────────────────────────────────
607
+
608
+ stacks_printed_this_frame = 0
609
+ this_frame_number = 0
610
+
611
+
612
+ def print_stack_trace(size=None, skip=0, stack=None, frames=None, watch=None,
613
+ max_str_len=200, max_items=5, max_depth=2, max_output=200,
614
+ exception=None, e=None, section=None, group=None, file=None,
615
+ print_args=True,
616
+ ignore_functions=("wrapper")):
617
+ """
618
+ Print a stack trace with optional variable watching.
619
+
620
+ Args:
621
+ size: Max number of frames to show (None = all).
622
+ skip: How many trailing frames to skip (-1 skips this function).
623
+ stack: Pre-extracted stack to use instead of the current one.
624
+ frames: Pre-captured live frames (from get_live_frames).
625
+ watch: List of variable names, dotted paths, or func(path) expressions.
626
+ max_str_len: Truncate strings beyond this length (None = no limit).
627
+ max_items: Max items in lists/dicts/sets (None = no limit).
628
+ max_depth: Max nesting depth before summary (None = no limit).
629
+ max_output: Hard cap on final formatted string per variable (None = no limit).
630
+ exception: Exception object — extracts frames and prints at the end.
631
+ section: Section label when used inside a trace_group.
632
+ group: TraceGroup instance — buffers output into the group.
633
+ file: Output stream override.
634
+ print_args: Auto-add function arguments to watches (default True).
635
+ ignore_functions: Frame function names to drop from the trace (repetitive
636
+ plumbing). The error frame is never dropped, even if its name
637
+ matches. Pass None/[] to keep all frames.
638
+ """
639
+ global stacks_printed_this_frame
640
+ global this_frame_number
641
+ if e is not None and exception is None:
642
+ exception = e
643
+
644
+ # Every printed stack trace marks the session as crashed - the launcher
645
+ # reads this sentinel at backup time and logs the log entry. On sys
646
+ # (not a module global) because this module is recompiled per run while
647
+ # the launcher reads from its own import identity. First error remains
648
+ # (root cause); count keeps ticking. Stamped BEFORE the per-frame rate
649
+ # limit so suppressed traces still register.
650
+ try:
651
+ rec = getattr(sys, '_lsd_session_crash', None)
652
+ if rec is None:
653
+ rec = {"error": None, "count": 0}
654
+ sys._lsd_session_crash = rec
655
+ rec["count"] += 1
656
+ if rec["error"] is None:
657
+ if exception is not None:
658
+ rec["error"] = f"{type(exception).__name__}: {exception}"
659
+ else:
660
+ rec["error"] = f"trace from {sys._getframe(1).f_code.co_name}()"
661
+ except Exception:
662
+ pass
663
+
664
+ if this_frame_number != Core.melty.frame_count:
665
+ this_frame_number = Core.melty.frame_count
666
+ stacks_printed_this_frame = 0
667
+
668
+ if stacks_printed_this_frame > 2:
669
+ return
670
+
671
+ stacks_printed_this_frame += 1
672
+
673
+ buf = io.StringIO()
674
+
675
+ if exception is not None:
676
+ frames = get_exception_frames(exception)
677
+ skip = None
678
+
679
+ if frames is None and stack is None:
680
+ frames = get_live_frames(skip_count=1)
681
+ elif frames is None and stack is not None:
682
+ formatted = traceback.format_list(
683
+ stack[:skip] if size is None else stack[-size:skip]
684
+ )
685
+ for frame in formatted:
686
+ buf.write(frame)
687
+ _dispatch(buf, group, file)
688
+ return
689
+
690
+ if skip == -1:
691
+ frames = frames[:-1]
692
+ elif skip is not None and skip < -1:
693
+ frames = frames[:skip]
694
+
695
+ if size is not None:
696
+ frames = frames[-size:]
697
+
698
+ watch_paths = list(watch) if watch else []
699
+ watch_paths.append("watch")
700
+
701
+ if group and section:
702
+ link = _caller_link()
703
+ group.write_section(f"{section}{link}")
704
+ elif not group:
705
+ thread_name = threading.current_thread().name
706
+ bar_color = _CYAN
707
+ title = "Exception Trace" if exception else "Stack Trace"
708
+ link = _caller_link()
709
+ color_a = '\u2500'
710
+ buf.write(f"{_BOLD}{bar_color}{color_a * 60}{_RESET}\n")
711
+ buf.write(f"{_BOLD}{bar_color}{title}{_RESET} {_DIM}[{thread_name}]{_RESET}{link}\n")
712
+ buf.write(f"{_BOLD}{bar_color}{color_a * 60}{_RESET}\n")
713
+
714
+ # Find the last user-code frame in an exception trace
715
+ error_frame_idx = None
716
+ if exception is not None:
717
+ for j in range(len(frames) - 1, -1, -1):
718
+ if _is_user_code(frames[j][0]):
719
+ error_frame_idx = j
720
+ break
721
+
722
+ # The full frame list (before the plumbing filter) goes into the saved
723
+ # report, so the Crash Reports window can open it as a stack trace view
724
+ # and with each user-code frame's locals as display strings, the values
725
+ # the view's live-value markers show (the same truncation as the watch
726
+ # table; live objects can't be saved).
727
+ # Gated like the Context menu's Code-tab capture: locals of any PROJECT
728
+ # frame (tests included), never library code.
729
+ from meltygui.code.fileref import is_editable_source
730
+ report_frames = []
731
+ for frame in frames or ():
732
+ scope = None
733
+ if frame[4] is not None and is_editable_source(frame[0]):
734
+ scope = _snapshot_locals(frame[4], max_str_len, max_items, max_depth, max_output)
735
+ report_frames.append((frame[0], frame[1], frame[2], scope))
736
+
737
+ # Drop repetitive plumbing frames, but never the error frame itself.
738
+ if ignore_functions:
739
+ error_frame = frames[error_frame_idx] if error_frame_idx is not None else None
740
+ near_error = False
741
+ error_frame_idx = (
742
+ next((k for k, frame in enumerate(frames) if frame is error_frame), None)
743
+ if error_frame is not None else None
744
+ )
745
+
746
+ if error_frame_idx is None:
747
+ error_frame_idx = len(frames) - 1
748
+
749
+ frames = [
750
+ frame for k, frame in enumerate(frames)
751
+ if (k == error_frame_idx) or abs(k - error_frame_idx) < 5 or frame[2] not in ignore_functions
752
+ ]
753
+
754
+ if frames:
755
+ for i, (filename, lineno, funcname, line_text, local_vars) in enumerate(frames):
756
+ rel = filename
757
+ is_mine = _is_user_code(filename)
758
+ is_error_frame = i == error_frame_idx
759
+
760
+ if is_mine:
761
+ if is_error_frame:
762
+ file_line = (
763
+ f"{_RED}File \"{rel}\", line {lineno},"
764
+ f" in {_BOLD}{funcname}{_RESET}"
765
+ )
766
+ else:
767
+ file_line = (
768
+ f"{_DIM}File \"{rel}\", line {lineno},"
769
+ f" in {_RESET}{_BOLD}{_WHITE}{funcname}{_RESET}"
770
+ )
771
+ else:
772
+ file_line = (
773
+ f"{_DIM}File \"{rel}\", line {lineno},"
774
+ f" in {funcname}{_RESET}"
775
+ )
776
+
777
+ code_line = ""
778
+ if line_text:
779
+ if is_mine:
780
+ code_line = _highlight(line_text.strip(), None)
781
+ else:
782
+ code_line = f"{_DIM}{line_text.strip()}{_RESET}"
783
+
784
+ # Resolve watch
785
+ table_rows = []
786
+ if is_mine and local_vars is not None:
787
+ # Build effective watch list: auto args + explicit watches
788
+ effective_watches = []
789
+ if print_args:
790
+ func_args = _get_func_args(filename, lineno, funcname, local_vars)
791
+ existing_roots = {_get_root_name(_parse_watch(w)[1]) for w in watch_paths}
792
+ for arg in func_args:
793
+ if arg not in existing_roots:
794
+ effective_watches.append(arg)
795
+ effective_watches.extend(watch_paths)
796
+
797
+ for expr in effective_watches:
798
+ # A watch resolves against live objects (truncate, repr,
799
+ # pformat) and can throw on any of them - one bad value
800
+ # must cost its row, not the whole trace.
801
+ try:
802
+ row = _resolve_watch(expr, filename, lineno, local_vars,
803
+ max_str_len, max_items, max_depth, max_output)
804
+ except Exception as watch_err:
805
+ row = (_highlight_inline(expr), f"{_DIM}?{_RESET}",
806
+ f"{_RED}<unprintable {type(watch_err).__name__}: {watch_err}>{_RESET}",
807
+ _make_link(filename, lineno))
808
+ if row is not None:
809
+ table_rows.append(row)
810
+
811
+ if table_rows:
812
+ try:
813
+ buf.write(_render_watch_table(file_line, code_line, table_rows,
814
+ error=is_error_frame))
815
+ except Exception:
816
+ buf.write(_render_frame_simple(file_line, code_line, table_rows))
817
+ else:
818
+ buf.write(f" {file_line}\n")
819
+ if code_line:
820
+ buf.write(f" {code_line}\n")
821
+
822
+ # Compilation errors (SyntaxError and friends) store the real error location
823
+ # on the exception itself, rather in the traceback frames - append it.
824
+ if isinstance(exception, SyntaxError) and exception.filename and exception.lineno:
825
+ err_file = exception.filename
826
+ err_line = exception.lineno
827
+ text = exception.text
828
+ if text is None:
829
+ text = linecache.getline(err_file, err_line)
830
+ is_mine = _is_user_code(err_file)
831
+ file_line = (
832
+ f"{_RED}File \"{err_file}\", line {err_line}{_RESET}"
833
+ if is_mine else
834
+ f"{_DIM}File \"{err_file}\", line {err_line}{_RESET}"
835
+ )
836
+ buf.write(f" {file_line}\n")
837
+ if text:
838
+ stripped = text.rstrip("\n")
839
+ code_line = (
840
+ _highlight(stripped.strip(), None) if is_mine
841
+ else f"{_DIM}{stripped.strip()}{_RESET}"
842
+ )
843
+ buf.write(f" {code_line}\n")
844
+ # Caret pointing at the error column, mirroring Python's own format.
845
+ if exception.offset:
846
+ indent = len(stripped) - len(stripped.lstrip())
847
+ caret_col = max(exception.offset - 1 - indent, 0)
848
+ buf.write(f" {' ' * caret_col}{_RED}^{_RESET}\n")
849
+
850
+ if exception is not None:
851
+ buf.write(f" {_RED}{_BOLD}{type(exception).__name__}: {exception}{_RESET}\n")
852
+
853
+ if not group:
854
+ bar_color = _CYAN
855
+ code = '\u2500'
856
+ buf.write(f"{_BOLD}{bar_color}{code * 60}{_RESET}\n")
857
+
858
+ _dispatch(buf, group, file)
859
+
860
+ # A grouped trace is one section of the group's own output and the group
861
+ # flushes as a whole, so only standalone traces become report files.
862
+ if not group:
863
+ # A plain trace (no exception) is titled by the function it was
864
+ # printed from, its innermost frame - not just "stack trace".
865
+ plain_label = (f"stack trace from {report_frames[-1][2]}()"
866
+ if exception is None and report_frames else None)
867
+ save_crash_report(buf.getvalue(), exception=exception, frames=report_frames,
868
+ error=plain_label)
869
+
870
+ if stacks_printed_this_frame > 2:
871
+ RED_BOLD = "\033[1m\033[31m"
872
+ print(f"{RED_BOLD} Not printing [{stacks_printed_this_frame}] stacks {_RESET}")
873
+ return
874
+
875
+
876
+ # ── Crash report files ───────────────────────────────────
877
+
878
+ # A saved frame keeps at most this many locals (the first bound win).
879
+ # [tint=(0.994, 0.872, 0.0)]
880
+ REPORT_LOCALS_PER_FRAME = 80
881
+
882
+
883
+ def _snapshot_locals(local_vars, max_str_len, max_items, max_depth, max_output):
884
+ """`{name: display string}` of a frame's locals for the saved report —
885
+ dunder names and modules dropped, every value rendered through the
886
+ watch table's `_truncate` + pformat, one bad value costing only its
887
+ entry (repr can raise on anything)."""
888
+ import types as _types
889
+ snapshot = {}
890
+ for name, value in list(local_vars.items())[:REPORT_LOCALS_PER_FRAME]:
891
+ if name.startswith("__") or isinstance(value, _types.ModuleType):
892
+ continue
893
+ try:
894
+ text = pprint.pformat(_truncate(value, max_str_len, max_items, max_depth), width=60)
895
+ except Exception as exc:
896
+ text = f"<unprintable {type(exc).__name__}>"
897
+ if max_output and len(text) > max_output:
898
+ text = text[:max_output] + f"\u2026({len(text)}ch)"
899
+ snapshot[name] = text
900
+ return snapshot
901
+
902
+ def crash_reports_dir():
903
+ """Where print_stack_trace writes its reports (Toggles.CrashReports.directory)."""
904
+ return Path(os.path.expanduser(Toggles.CrashReports.directory))
905
+
906
+
907
+ # The last millisecond stamp handed out and how many reports shared it -
908
+ # every name carries a 3-digit sequence within its millisecond so names stay
909
+ # unique AND sort in write order whatever the error label (probing the disk
910
+ # instead let a pruned base name be reused and sort as the oldest report).
911
+ _last_report_stamp = [None, 0]
912
+
913
+
914
+ def _report_stem(exception):
915
+ """`2026-09-04_14-03-22-517-000_ZeroDivisionError` — sorts by time as
916
+ text, and the error name makes the file list readable on its own."""
917
+ now = time.time()
918
+ stamp = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime(now))
919
+ stamp = f"{stamp}-{int((now - int(now)) * 1000):03d}"
920
+ label = type(exception).__name__ if exception is not None else "trace"
921
+ label = re.sub(r"[^A-Za-z0-9_]+", "_", label)[:60]
922
+ with _print_lock:
923
+ if _last_report_stamp[0] == stamp:
924
+ _last_report_stamp[1] += 1
925
+ else:
926
+ _last_report_stamp[0], _last_report_stamp[1] = stamp, 0
927
+ sequence = _last_report_stamp[1]
928
+ return f"{stamp}-{sequence:03d}_{label}"
929
+
930
+
931
+ def git_head_commit(root=None):
932
+ """`(sha, branch)` of the checkout at `root` (the project root), read
933
+ straight off `.git` — HEAD → its ref → loose ref file or packed-refs —
934
+ with no subprocess (a git call per crash would be a posix_spawn on the
935
+ render thread for a value that changes once per commit). (None, None)
936
+ when there is no git checkout; memoized on HEAD's and the ref file's
937
+ mtimes so a commit or checkout is seen on the next report."""
938
+ from meltygui.core.runtime.paths import application_root
939
+ root = Path(root) if root is not None else application_root()
940
+ try:
941
+ git_dir = root / ".git"
942
+ if git_dir.is_file(): # a worktree: `gitdir: <path>`
943
+ pointer = git_dir.read_text().strip()
944
+ if pointer.startswith("gitdir:"):
945
+ git_dir = Path(pointer.split(":", 1)[1].strip())
946
+ if not git_dir.is_absolute():
947
+ git_dir = root / git_dir
948
+ head_path = git_dir / "HEAD"
949
+ head = head_path.read_text().strip()
950
+ if not head.startswith("ref:"):
951
+ return head[:40], None # detached HEAD
952
+ ref = head.split(":", 1)[1].strip()
953
+ branch = ref.rsplit("/", 1)[-1]
954
+ ref_path = git_dir / ref
955
+ if ref_path.is_file():
956
+ return ref_path.read_text().strip()[:40], branch
957
+ # common dir for worktrees: refs live in the main repo's .git
958
+ common = git_dir / "commondir"
959
+ if common.is_file():
960
+ main_dir = (git_dir / common.read_text().strip()).resolve()
961
+ candidate = main_dir / ref
962
+ if candidate.is_file():
963
+ return candidate.read_text().strip()[:40], branch
964
+ git_dir = main_dir
965
+ packed = git_dir / "packed-refs"
966
+ if packed.is_file():
967
+ for line in packed.read_text().splitlines():
968
+ if line.endswith(" " + ref):
969
+ return line.split(" ", 1)[0][:40], branch
970
+ except OSError:
971
+ pass
972
+ return None, None
973
+
974
+
975
+ def save_crash_report(text, exception=None, thread_name=None, frames=None, error=None):
976
+ """Write one printed trace as an ANSI-stripped text file under
977
+ crash_reports_dir() and return its path (None when saving is off or
978
+ the write failed — a crash report must never raise into the trace
979
+ that produced it). The first lines are a `key: value` header the
980
+ Crash Reports window reads without loading the whole file — `commit`
981
+ the checkout's HEAD sha and branch (git_head_commit), `frames`
982
+ is the trace's (path, lineno, function) list as JSON, outermost first,
983
+ which the window hands to draw_stack_trace, and `locals` the matching
984
+ list of per-frame {name: display string} snapshots (null for frames
985
+ without one) the view shows as values; the printed trace follows after
986
+ a blank line. `frames` entries may carry the snapshot as a 4th item.
987
+ Oldest files past Toggles.CrashReports.max_reports are deleted."""
988
+ if not Toggles.CrashReports.auto_save:
989
+ return None
990
+ try:
991
+ directory = crash_reports_dir()
992
+ directory.mkdir(parents=True, exist_ok=True)
993
+ thread_name = thread_name or threading.current_thread().name
994
+ if error is None:
995
+ error = (f"{type(exception).__name__}: {exception}" if exception is not None
996
+ else "stack trace")
997
+ header = (f"time: {time.strftime('%Y-%m-%d %H:%M:%S')}\n"
998
+ f"thread: {thread_name}\n"
999
+ f"error: {error.splitlines()[0] if error else ''}\n")
1000
+ commit, branch = git_head_commit()
1001
+ if commit:
1002
+ header += f"commit: {commit}{(' ' + branch) if branch else ''}\n"
1003
+ if frames:
1004
+ frame_rows = [[str(frame[0]), int(frame[1]), str(frame[2])] for frame in frames]
1005
+ header += f"frames: {json.dumps(frame_rows)}\n"
1006
+ scopes = [frame[3] if len(frame) > 3 and isinstance(frame[3], dict) else None
1007
+ for frame in frames]
1008
+ if any(scope for scope in scopes):
1009
+ header += f"locals: {json.dumps(scopes, ensure_ascii=False)}\n"
1010
+ header += "\n"
1011
+ path = directory / f"{_report_stem(exception)}.txt"
1012
+ path.write_text(header + _ANSI_RE.sub("", text), encoding="utf-8")
1013
+ _prune_crash_reports(directory)
1014
+ try:
1015
+ from meltygui.model.trace_report_model import reports_changed
1016
+ reports_changed()
1017
+ except Exception:
1018
+ pass # window's not loaded yet: nothing to repaint
1019
+ return path
1020
+ except Exception:
1021
+ return None
1022
+
1023
+
1024
+ def _prune_crash_reports(directory):
1025
+ limit = Toggles.CrashReports.max_reports
1026
+ if not limit or limit <= 0:
1027
+ return
1028
+ files = sorted(directory.glob("*.txt")) # names sort by time
1029
+ for stale in files[:max(0, len(files) - limit)]:
1030
+ try:
1031
+ stale.unlink()
1032
+ except OSError:
1033
+ pass
1034
+
1035
+
1036
+ # ── Path helpers ─────────────────────────────────────────
1037
+
1038
+ def _rel_path(filepath):
1039
+ """Get a relative path with ./ prefix."""
1040
+ try:
1041
+ rel = os.path.relpath(filepath)
1042
+ if not rel.startswith("."):
1043
+ rel = "./" + rel
1044
+ return rel
1045
+ except ValueError:
1046
+ return os.path.abspath(filepath)
1047
+
1048
+
1049
+ def _make_link(filepath, line):
1050
+ """Short clickable link for the table's link column."""
1051
+ rel = _rel_path(filepath)
1052
+ line = line or 1
1053
+
1054
+ if _IDE in ("intellij", "traceback"):
1055
+ return f"{_DIM}File \"{_BLUE}{rel}{_DIM}\", line {line}{_RESET}"
1056
+
1057
+ abs_path = os.path.abspath(filepath)
1058
+ if _IDE and _IDE in _IDE_SCHEMES:
1059
+ uri = _IDE_SCHEMES[_IDE].format(path=abs_path, line=line)
1060
+ else:
1061
+ uri = f"file://{abs_path}:{line}"
1062
+ return f"\033]8;;{uri}\033\\{_DIM}:{line}{_RESET}\033]8;;\033\\"
1063
+
1064
+
1065
+ # ── Output dispatch ──────────────────────────────────────
1066
+
1067
+ def _dispatch(buf, group=None, file=None):
1068
+ output = buf.getvalue()
1069
+ if group:
1070
+ group.buf.write(output)
1071
+ elif file:
1072
+ with _print_lock:
1073
+ file.write(output)
1074
+ file.flush()
1075
+ else:
1076
+ with _print_lock:
1077
+ sys.stdout.write(output)
1078
+ sys.stdout.flush()
1079
+
1080
+
1081
+ # ── Frame extraction ─────────────────────────────────────
1082
+
1083
+ def get_live_frames(skip_count=0):
1084
+ """Walk the call stack and return frame info with local variables."""
1085
+ raw_frames = inspect.stack()
1086
+ results = []
1087
+ for frame_info in raw_frames[skip_count + 1:]:
1088
+ frame_obj = frame_info[0]
1089
+ results.append((
1090
+ frame_info[1],
1091
+ frame_info[2],
1092
+ frame_info[3],
1093
+ (frame_info[4] or [""])[0],
1094
+ dict(frame_obj.f_locals),
1095
+ ))
1096
+ results.reverse()
1097
+ return results
1098
+
1099
+
1100
+ def get_exception_frames(e):
1101
+ """Extract live frames from a caught exception's traceback."""
1102
+ tb = e.__traceback__
1103
+ if tb is None:
1104
+ return []
1105
+ results = []
1106
+ while tb is not None:
1107
+ frame_obj = tb.tb_frame
1108
+ results.append((
1109
+ frame_obj.f_code.co_filename,
1110
+ tb.tb_lineno,
1111
+ frame_obj.f_code.co_name,
1112
+ linecache.getline(frame_obj.f_code.co_filename, tb.tb_lineno),
1113
+ dict(frame_obj.f_locals),
1114
+ ))
1115
+ tb = tb.tb_next
1116
+ return results
1117
+
1118
+
1119
+ # ── Watch resolution ─────────────────────────────────────
1120
+
1121
+ _ATTR = 'attr'
1122
+ _INDEX = 'index'
1123
+
1124
+
1125
+ def _tokenize_path(path):
1126
+ """
1127
+ Break a path string into tagged access tokens.
1128
+
1129
+ "draw_state.name" -> [("attr", "draw_state"), ("attr", "name")]
1130
+ "my_dict['key']" -> [("attr", "my_dict"), ("index", "key")]
1131
+ "items[0].name" -> [("attr", "items"), ("index", 0), ("attr", "name")]
1132
+ "nested['a']['b'].val" -> [("attr", "nested"), ("index", "a"), ("index", "b"), ("attr", "val")]
1133
+ """
1134
+ tokens = []
1135
+ for part in re.split(r'\.', path):
1136
+ segments = re.split(r'(\[[^\]]*\])', part)
1137
+ for seg in segments:
1138
+ seg = seg.strip()
1139
+ if not seg:
1140
+ continue
1141
+ if seg.startswith("[") and seg.endswith("]"):
1142
+ inner = seg[1:-1].strip()
1143
+ try:
1144
+ tokens.append((_INDEX, int(inner)))
1145
+ except ValueError:
1146
+ tokens.append((_INDEX, inner.strip("\"'")))
1147
+ else:
1148
+ tokens.append((_ATTR, seg))
1149
+ return tokens
1150
+
1151
+
1152
+ def _resolve_path(path, local_vars):
1153
+ """
1154
+ Walk a dotted/indexed path against local variables.
1155
+ Dot access tries getattr first, then obj[key].
1156
+ Bracket access uses obj[key] only.
1157
+ """
1158
+ tokens = _tokenize_path(path)
1159
+ if not tokens:
1160
+ return False, None
1161
+ _, root = tokens[0]
1162
+ if root not in local_vars:
1163
+ return False, None
1164
+ obj = local_vars[root]
1165
+ for kind, token in tokens[1:]:
1166
+ try:
1167
+ if kind == _ATTR:
1168
+ try:
1169
+ obj = getattr(obj, token)
1170
+ except AttributeError:
1171
+ obj = obj[token]
1172
+ else:
1173
+ obj = obj[token]
1174
+ except (AttributeError, IndexError, KeyError, TypeError):
1175
+ return False, None
1176
+ return True, obj
1177
+
1178
+
1179
+ def _get_root_name(path):
1180
+ """Extract the top-level variable name from a dotted/indexed path."""
1181
+ tokens = _tokenize_path(path)
1182
+ return tokens[0][1] if tokens else path
1183
+
1184
+
1185
+ def _find_assignment(filepath, current_line, var_name, search_range=200):
1186
+ """Search backward from current_line to find where var_name is assigned."""
1187
+ try:
1188
+ with open(filepath, 'r') as f:
1189
+ lines = f.readlines()
1190
+ except (OSError, IOError):
1191
+ return None
1192
+ escaped = re.escape(var_name)
1193
+ patterns = [
1194
+ rf'^\s*{escaped}\s*=[^=]',
1195
+ rf'^\s*{escaped}\s*:',
1196
+ rf'^\s*for\s+{escaped}\s+in\s',
1197
+ rf'[\(,]\s*{escaped}\s*[,\)=:]',
1198
+ rf'^\s*with\s+.*\bas\s+{escaped}',
1199
+ rf'^\s*{escaped}\s*[+\-*|&^]='
1200
+ ]
1201
+ compiled = [re.compile(p) for p in patterns]
1202
+ start = min(current_line - 1, len(lines)) - 1
1203
+ stop = max(start - search_range, 0)
1204
+ for i in range(start, stop, -1):
1205
+ for pat in compiled:
1206
+ if pat.search(lines[i]):
1207
+ return i + 1
1208
+ return None
1209
+
1210
+
1211
+ # ── Value truncation ─────────────────────────────────────
1212
+
1213
+ def _truncate(value, max_str_len=120, max_items=5, max_depth=3, _current_depth=0):
1214
+ """Recursively truncate values for display."""
1215
+ if max_depth is not None and _current_depth >= max_depth:
1216
+ return _summarize(value)
1217
+ next_depth = _current_depth + 1
1218
+ if isinstance(value, str):
1219
+ if max_str_len and len(value) > max_str_len:
1220
+ return value[:max_str_len] + f"\u2026({len(value)}ch)"
1221
+ return value
1222
+ if isinstance(value, bytes):
1223
+ if max_str_len and len(value) > max_str_len:
1224
+ return value[:max_str_len] + f"\u2026({len(value)}b)".encode()
1225
+ return value
1226
+ if isinstance(value, dict):
1227
+ items = list(value.items())
1228
+ show = max_items or len(items)
1229
+ truncated = {
1230
+ _truncate(k, max_str_len, max_items, max_depth, next_depth):
1231
+ _truncate(v, max_str_len, max_items, max_depth, next_depth)
1232
+ for k, v in items[:show]
1233
+ }
1234
+ remaining = len(items) - show
1235
+ if remaining > 0:
1236
+ truncated[f"\u2026+{remaining}"] = "\u2026"
1237
+ return truncated
1238
+ if isinstance(value, (list, tuple)):
1239
+ items = list(value)
1240
+ show = max_items or len(items)
1241
+ truncated = [_truncate(item, max_str_len, max_items, max_depth, next_depth)
1242
+ for item in items[:show]]
1243
+ remaining = len(items) - show
1244
+ if remaining > 0:
1245
+ truncated.append(f"\u2026+{remaining}")
1246
+ if not isinstance(value, tuple):
1247
+ return truncated
1248
+ try:
1249
+ # namedtuples take positional fields, not an iterable \u2014 and a
1250
+ # truncated one no longer has the right arity at all
1251
+ if hasattr(value, '_fields'):
1252
+ if len(truncated) == len(value):
1253
+ return type(value)(*truncated)
1254
+ return tuple(truncated)
1255
+ return type(value)(truncated)
1256
+ except Exception:
1257
+ return tuple(truncated)
1258
+ if isinstance(value, (set, frozenset)):
1259
+ items = list(value)
1260
+ show = max_items or len(items)
1261
+ truncated = {_truncate(item, max_str_len, max_items, max_depth, next_depth)
1262
+ for item in items[:show]}
1263
+ remaining = len(items) - show
1264
+ if remaining > 0:
1265
+ truncated.add(f"\u2026+{remaining}")
1266
+ return truncated
1267
+ if hasattr(value, 'shape'):
1268
+ return _summarize(value)
1269
+ return value
1270
+
1271
+
1272
+ def _summarize(value):
1273
+ """One-line summary for values too deep or complex to expand."""
1274
+ t = type(value).__name__
1275
+ if hasattr(value, 'shape'):
1276
+ return f"<{t} {value.shape} {getattr(value, 'dtype', '?')}>"
1277
+ if isinstance(value, dict):
1278
+ return f"<dict {len(value)}keys>"
1279
+ if isinstance(value, (list, tuple)):
1280
+ return f"<{t} len={len(value)}>"
1281
+ if isinstance(value, (set, frozenset)):
1282
+ return f"<{t} len={len(value)}>"
1283
+ if isinstance(value, str):
1284
+ return f"<str {len(value)}ch>"
1285
+ if isinstance(value, bytes):
1286
+ return f"<bytes {len(value)}b>"
1287
+ return f"<{t}>"
1288
+
1289
+
1290
+ # ── GLFW management ───────────────────────────────────────
1291
+
1292
+ _needs_render = threading.Event()
1293
+ frames_left = 0
1294
+
1295
+
1296
+ # [tint=(0.191, 0.328, 0.191), show_tint=True]
1297
+ def request_render(for_frames: int | None = None):
1298
+ # Can be called from ANY thread - including worker threads (PTY readers, or
1299
+ # claude-session poller) that start at import, before glfw.init() and the main
1300
+ # window exist. The glfw.get_current_context() guard below itself calls INTO glfw,
1301
+ # which raises GLFWError "The GLFW library is not initialized" when called before
1302
+ # init - i.e. the guard check is what produces the error. So gate on the GLFW
1303
+ # window FIRST, a pure-Python object-attr check (None until create_window), no glfw
1304
+ # call. Lazy import because Meltygui imports this module (circular at top level); meltygui
1305
+ # is fully loaded by the time any thread calls request_render at start.
1306
+ from meltygui.core.melty import Melty
1307
+ if Melty.glfw_window is None:
1308
+ return
1309
+ # NOTE: no glfw.get_current_context() readiness check here - it returns the
1310
+ # context current on the CALLING thread, which is None on every worker
1311
+ # thread, so it silently dropped exactly the cross-thread wakes this
1312
+ # function exists for (an idle loop blocks in glfw.wait_events; a
1313
+ # task completion must post_empty_event to produce a frame). The
1314
+ # window-exists gate above covers pre-init; the try/except at the bottom
1315
+ # covers mid-shutdown teardown.
1316
+
1317
+ global frames_left
1318
+ if frames_left > 0:
1319
+ frames_left -= 1
1320
+
1321
+ if for_frames is not None:
1322
+ frames_left = for_frames
1323
+
1324
+ # "request_render" notify function: every call toasts its caller's stack
1325
+ # (click → open the call site in the editor), gated like the invalidate
1326
+ # column on InvalidateTracker.enable (E hotkey). notify() collapses
1327
+ # repeats of one call site into one counted entry, and is never urgent here
1328
+ # - an urgent notify would call back into request_render.
1329
+ if (Toggles.InvalidateTracker.enable or Toggles.InvalidateTracker.invalidate_request_render
1330
+ or Toggles.InvalidateTracker.invalidate_stack_trace):
1331
+ from meltygui.core.diagnostics.notifications import notify
1332
+ from meltygui.core.diagnostics.notifications import capture_stack
1333
+ stack = capture_stack(skip_files=("glfw_utils.py",), skip_funcs=("request_render",))
1334
+ if stack:
1335
+ fn = stack[-1][2]
1336
+ notify(f"request_render [{fn}] {threading.current_thread().name}",
1337
+ tint=(0.4, 0.8, 1.0), tag="request_render", stack=stack, urgent=False)
1338
+
1339
+ _needs_render.set()
1340
+ try:
1341
+ glfw.post_empty_event()
1342
+ except Exception:
1343
+ pass # glfw torn down mid-call (shutdown/restart) - nothing to wake