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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- 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
|