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,100 @@
|
|
|
1
|
+
"""Codex catalog capabilities and saved thread settings, shared with the UI."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from meltygui.chat.chat_proxy import epoch_seconds
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def model_service_tiers(row):
|
|
9
|
+
if "serviceTiers" in row:
|
|
10
|
+
return tuple(tier for tier in row["serviceTiers"] if tier.get("id"))
|
|
11
|
+
return tuple({"id": tier, "name": "Fast" if tier in ("fast", "priority") else tier}
|
|
12
|
+
for tier in row.get("additionalSpeedTiers", []))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fast_service_tier(proxy, model):
|
|
16
|
+
return next((tier["id"] for tier in getattr(proxy, "model_service_tiers", {}).get(model, ())
|
|
17
|
+
if tier["id"] in ("priority", "fast") or tier.get("name", "").lower() == "fast"), None)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def effective_settings(chat, defaults, default_model=None):
|
|
21
|
+
saved = chat.get("codex_settings", {})
|
|
22
|
+
saved_at = chat.get("codex_settings_at", 0)
|
|
23
|
+
metadata = chat.metadata
|
|
24
|
+
result = {}
|
|
25
|
+
for field, config_field in (("model", "model"), ("effort", "model_reasoning_effort"),
|
|
26
|
+
("service_tier", "service_tier")):
|
|
27
|
+
explicit = field in metadata and (field != "model" or metadata.get("model_explicit"))
|
|
28
|
+
if explicit and metadata.get(field + "_selected_at", 0) >= saved_at:
|
|
29
|
+
value = metadata[field]
|
|
30
|
+
if field != "service_tier" and value in (None, "", "default"):
|
|
31
|
+
value = defaults.get(config_field)
|
|
32
|
+
result[field] = value
|
|
33
|
+
elif field in saved:
|
|
34
|
+
result[field] = saved[field]
|
|
35
|
+
elif config_field in defaults and (field != "service_tier" or defaults[config_field] is not None):
|
|
36
|
+
result[field] = defaults[config_field]
|
|
37
|
+
result["model"] = result.get("model") or default_model
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ThreadSettingsReader:
|
|
42
|
+
"""Worker-only incremental log reader; initial reads scan backwards."""
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self.files = {}
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def parse(line):
|
|
48
|
+
if b'"thread_settings_applied"' not in line:
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
row = json.loads(line)
|
|
52
|
+
except (ValueError, UnicodeError):
|
|
53
|
+
return None
|
|
54
|
+
payload = row.get("payload") or {}
|
|
55
|
+
if row.get("type") != "event_msg" or payload.get("type") != "thread_settings_applied":
|
|
56
|
+
return None
|
|
57
|
+
values = payload.get("thread_settings") or {}
|
|
58
|
+
return {"codex_settings": {target: values[source] for source, target in
|
|
59
|
+
(("model", "model"), ("reasoning_effort", "effort"), ("service_tier", "service_tier"))
|
|
60
|
+
if source in values}, "codex_settings_at": epoch_seconds(row.get("timestamp"))}
|
|
61
|
+
|
|
62
|
+
def read(self, path):
|
|
63
|
+
if not path:
|
|
64
|
+
return {}
|
|
65
|
+
path = Path(path)
|
|
66
|
+
previous = self.files.get(path)
|
|
67
|
+
try:
|
|
68
|
+
stat = path.stat()
|
|
69
|
+
signature = (stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
|
70
|
+
if previous and previous[0] == signature:
|
|
71
|
+
return previous[2]
|
|
72
|
+
with path.open("rb") as handle:
|
|
73
|
+
if previous and previous[0][0] == stat.st_ino and stat.st_size > previous[0][1]:
|
|
74
|
+
offset, settings = previous[1:]
|
|
75
|
+
handle.seek(offset)
|
|
76
|
+
while line := handle.readline():
|
|
77
|
+
if not line.endswith(b'\n'):
|
|
78
|
+
break
|
|
79
|
+
settings = self.parse(line) or settings
|
|
80
|
+
offset = handle.tell()
|
|
81
|
+
else:
|
|
82
|
+
offset, settings = stat.st_size, {}
|
|
83
|
+
position, tail, first = stat.st_size, b"", True
|
|
84
|
+
while position and not settings:
|
|
85
|
+
count = min(position, 65536)
|
|
86
|
+
position -= count
|
|
87
|
+
handle.seek(position)
|
|
88
|
+
parts = (handle.read(count) + tail).split(b"\n")
|
|
89
|
+
tail = parts.pop(0) if position else b""
|
|
90
|
+
if first and parts:
|
|
91
|
+
offset -= len(parts.pop()) # exclude the partial trailing record
|
|
92
|
+
first = False
|
|
93
|
+
for line in reversed(parts):
|
|
94
|
+
settings = self.parse(line) or {}
|
|
95
|
+
if settings:
|
|
96
|
+
break
|
|
97
|
+
self.files[path] = (signature, offset, settings)
|
|
98
|
+
return settings
|
|
99
|
+
except OSError:
|
|
100
|
+
return previous[2] if previous else {}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Multiplexed, persistent version of the account-only stdio transport."""
|
|
2
|
+
import json
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from meltygui.completion.providers.codex_accounts import AppServer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CodexTransport(AppServer):
|
|
10
|
+
def __init__(self, home, executable="", timeout=30, on_event=None):
|
|
11
|
+
self.pending = {}
|
|
12
|
+
self.write_lock = threading.Lock()
|
|
13
|
+
self.pending_lock = threading.Lock()
|
|
14
|
+
self.on_event = on_event or (lambda event: None)
|
|
15
|
+
super().__init__(home, executable, timeout)
|
|
16
|
+
|
|
17
|
+
def _send(self, message):
|
|
18
|
+
with self.write_lock:
|
|
19
|
+
super()._send(message)
|
|
20
|
+
|
|
21
|
+
def _read(self):
|
|
22
|
+
try:
|
|
23
|
+
for line in self.process.stdout:
|
|
24
|
+
message = json.loads(line)
|
|
25
|
+
if "method" in message:
|
|
26
|
+
self.on_event(message)
|
|
27
|
+
else:
|
|
28
|
+
with self.pending_lock:
|
|
29
|
+
response = self.pending.get(message.get("id"))
|
|
30
|
+
if response is not None:
|
|
31
|
+
response.put(message)
|
|
32
|
+
except (ValueError, OSError) as error:
|
|
33
|
+
self.on_event({"method": "transport/error", "params": {"message": str(error)}})
|
|
34
|
+
finally:
|
|
35
|
+
with self.pending_lock:
|
|
36
|
+
for response in self.pending.values():
|
|
37
|
+
response.put({"error": {"message": "Codex disconnected"}})
|
|
38
|
+
self.on_event({"method": "transport/error", "params": {"message": "Codex disconnected"}})
|
|
39
|
+
|
|
40
|
+
def request(self, method, params=None):
|
|
41
|
+
response = queue.Queue()
|
|
42
|
+
with self.pending_lock:
|
|
43
|
+
self.sequence += 1
|
|
44
|
+
identifier = self.sequence
|
|
45
|
+
self.pending[identifier] = response
|
|
46
|
+
try:
|
|
47
|
+
self._send({"method": method, "id": identifier, "params": params or {}})
|
|
48
|
+
try:
|
|
49
|
+
message = response.get(timeout=self.timeout)
|
|
50
|
+
except queue.Empty:
|
|
51
|
+
raise TimeoutError(f"Codex timed out: {method}") from None
|
|
52
|
+
if "error" in message:
|
|
53
|
+
raise RuntimeError(message["error"].get("message", "Codex request failed"))
|
|
54
|
+
return message.get("result") or {}
|
|
55
|
+
finally:
|
|
56
|
+
with self.pending_lock:
|
|
57
|
+
self.pending.pop(identifier, None)
|
|
58
|
+
|
|
59
|
+
def answer(self, request_id, result=None, error=None):
|
|
60
|
+
self._send({"id": request_id, **({"error": error} if error else {"result": result})})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Static command inspection. Never execute commands, import code, or read files."""
|
|
2
|
+
import ast
|
|
3
|
+
import posixpath
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def shell_body(command):
|
|
9
|
+
"""Remove a literal shell -c wrapper, retaining the original script text."""
|
|
10
|
+
for _ in range(3):
|
|
11
|
+
try:
|
|
12
|
+
words = shlex.split(command)
|
|
13
|
+
except ValueError:
|
|
14
|
+
break
|
|
15
|
+
if len(words) >= 3 and posixpath.basename(words[0]) in ('bash', 'sh', 'zsh'):
|
|
16
|
+
flag = next((i for i, word in enumerate(words[1:], 1)
|
|
17
|
+
if word.startswith('-') and 'c' in word), None)
|
|
18
|
+
if flag is not None and flag + 1 < len(words):
|
|
19
|
+
command = words[flag + 1]
|
|
20
|
+
continue
|
|
21
|
+
break
|
|
22
|
+
return command
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_command(command, actions=(), scripts=None, cwd=""):
|
|
26
|
+
"""Return literal file references and captured script bodies.
|
|
27
|
+
|
|
28
|
+
`access` is an operation requested by the command, not proof it succeeded.
|
|
29
|
+
Unknown variables, substitutions, imports and function calls stay unknown.
|
|
30
|
+
"""
|
|
31
|
+
files = {}
|
|
32
|
+
scripts = scripts if scripts is not None else {}
|
|
33
|
+
|
|
34
|
+
def add(path, access='read', explicit=False):
|
|
35
|
+
if not isinstance(path, str) or not path or path.startswith('-'):
|
|
36
|
+
return
|
|
37
|
+
if any(char in path for char in '\n\r$*?{}|') or '://' in path:
|
|
38
|
+
return
|
|
39
|
+
if not explicit and not re.fullmatch(r'[\w./@ +\-]+\.[\w]+', path):
|
|
40
|
+
return
|
|
41
|
+
path = posixpath.normpath(posixpath.join(cwd, path))
|
|
42
|
+
old = files.get(path)
|
|
43
|
+
if old is None or access == 'write':
|
|
44
|
+
files[path] = access
|
|
45
|
+
|
|
46
|
+
def python(source):
|
|
47
|
+
try:
|
|
48
|
+
tree = ast.parse(source)
|
|
49
|
+
except (SyntaxError, ValueError):
|
|
50
|
+
return
|
|
51
|
+
env = {}
|
|
52
|
+
|
|
53
|
+
def resolve(node):
|
|
54
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
55
|
+
return node.value
|
|
56
|
+
if isinstance(node, ast.Name):
|
|
57
|
+
return env.get(node.id)
|
|
58
|
+
if isinstance(node, ast.BinOp):
|
|
59
|
+
left, right = resolve(node.left), resolve(node.right)
|
|
60
|
+
if left is not None and right is not None:
|
|
61
|
+
if isinstance(node.op, ast.Div):
|
|
62
|
+
return posixpath.join(left, right)
|
|
63
|
+
if isinstance(node.op, ast.Add):
|
|
64
|
+
return left + right
|
|
65
|
+
if isinstance(node, ast.Call):
|
|
66
|
+
name = getattr(node.func, 'id', getattr(node.func, 'attr', ''))
|
|
67
|
+
if name in ('Path', 'PurePath', 'PurePosixPath') and node.args:
|
|
68
|
+
parts = [resolve(arg) for arg in node.args]
|
|
69
|
+
if all(part is not None for part in parts):
|
|
70
|
+
return posixpath.join(*parts)
|
|
71
|
+
if name == 'open' and node.args:
|
|
72
|
+
return resolve(node.args[0])
|
|
73
|
+
if isinstance(node.func, ast.Attribute) and name == 'joinpath':
|
|
74
|
+
base = resolve(node.func.value)
|
|
75
|
+
parts = [resolve(arg) for arg in node.args]
|
|
76
|
+
if base is not None and all(part is not None for part in parts):
|
|
77
|
+
return posixpath.join(base, *parts)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def visit(node):
|
|
81
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
82
|
+
return # a definition is not evidence that its body ran
|
|
83
|
+
if isinstance(node, ast.Assign):
|
|
84
|
+
value = resolve(node.value)
|
|
85
|
+
for target in node.targets:
|
|
86
|
+
if isinstance(target, ast.Name):
|
|
87
|
+
env[target.id] = value
|
|
88
|
+
if isinstance(node, ast.With):
|
|
89
|
+
for item in node.items:
|
|
90
|
+
if isinstance(item.optional_vars, ast.Name):
|
|
91
|
+
env[item.optional_vars.id] = resolve(item.context_expr)
|
|
92
|
+
if isinstance(node, ast.For) and isinstance(node.target, ast.Name) and isinstance(node.iter, (ast.List, ast.Tuple)):
|
|
93
|
+
before = dict(env)
|
|
94
|
+
for value in node.iter.elts[:100]:
|
|
95
|
+
env[node.target.id] = resolve(value)
|
|
96
|
+
for child in node.body:
|
|
97
|
+
visit(child)
|
|
98
|
+
env.clear()
|
|
99
|
+
env.update(before)
|
|
100
|
+
return
|
|
101
|
+
if isinstance(node, ast.Call):
|
|
102
|
+
name = getattr(node.func, 'id', getattr(node.func, 'attr', ''))
|
|
103
|
+
if name == 'open' and node.args:
|
|
104
|
+
mode = resolve(node.args[1]) if len(node.args) > 1 else 'r'
|
|
105
|
+
mode = next((resolve(kw.value) for kw in node.keywords if kw.arg == 'mode'), mode)
|
|
106
|
+
add(resolve(node.args[0]), 'write' if mode and any(c in mode for c in 'wax+') else 'read', True)
|
|
107
|
+
if isinstance(node.func, ast.Attribute):
|
|
108
|
+
path = resolve(node.func.value)
|
|
109
|
+
if name in ('write_text', 'write_bytes', 'write', 'writelines', 'unlink', 'touch'):
|
|
110
|
+
add(path, 'write', True)
|
|
111
|
+
elif name in ('read_text', 'read_bytes', 'read', 'readlines'):
|
|
112
|
+
add(path, 'read', True)
|
|
113
|
+
elif name in ('rename', 'replace') and path is not None and node.args:
|
|
114
|
+
add(path, 'write', True)
|
|
115
|
+
add(resolve(node.args[0]), 'write', True)
|
|
116
|
+
for child in ast.iter_child_nodes(node):
|
|
117
|
+
visit(child)
|
|
118
|
+
visit(tree)
|
|
119
|
+
|
|
120
|
+
source = shell_body(str(command))
|
|
121
|
+
# Heredocs expose Python directly, or the body of a script written
|
|
122
|
+
# in one history item and run by a later item. Remember only the
|
|
123
|
+
# literal scripts; never consult the filesystem for an indirect script.
|
|
124
|
+
pattern = re.compile(r"^([^\n]*?)<<-?\s*(['\"]?)([A-Za-z_]\w*)\2([^\n]*)\n(.*?)^\3[ \t]*(?:\n|$)", re.M | re.S)
|
|
125
|
+
segments = []
|
|
126
|
+
last = 0
|
|
127
|
+
for match in pattern.finditer(source):
|
|
128
|
+
segments.append(source[last:match.start()])
|
|
129
|
+
head, body = match[1] + match[4], match[5]
|
|
130
|
+
try:
|
|
131
|
+
words = shlex.split(head)
|
|
132
|
+
except ValueError:
|
|
133
|
+
words = []
|
|
134
|
+
if any(re.fullmatch(r'python(?:\d+(?:\.\d+)?)?', posixpath.basename(w)) for w in words[:2]):
|
|
135
|
+
python(body)
|
|
136
|
+
else:
|
|
137
|
+
target = re.search(r'>\s*([^\s]+)', head)
|
|
138
|
+
if target:
|
|
139
|
+
path = target[1].strip("'\"")
|
|
140
|
+
add(path, 'write', True)
|
|
141
|
+
if path.endswith('.py'):
|
|
142
|
+
scripts[posixpath.normpath(path)] = body
|
|
143
|
+
if 'apply_patch' in head:
|
|
144
|
+
for path in re.findall(r'^\*\*\* (?:Update|Add|Delete) File: (.+)$', body, re.M):
|
|
145
|
+
add(path, 'write', True)
|
|
146
|
+
segments.append(head + '\n')
|
|
147
|
+
last = match.end()
|
|
148
|
+
segments.append(source[last:])
|
|
149
|
+
source_without_bodies = ''.join(segments)
|
|
150
|
+
try:
|
|
151
|
+
lexer = shlex.shlex(source_without_bodies, posix=True, punctuation_chars=';&|<>\n')
|
|
152
|
+
lexer.whitespace_split = True
|
|
153
|
+
lexer.whitespace = ' \t\r'
|
|
154
|
+
words = list(lexer)
|
|
155
|
+
except ValueError:
|
|
156
|
+
words = []
|
|
157
|
+
groups, group = [], []
|
|
158
|
+
for word in words + [';']:
|
|
159
|
+
if word and all(char in ';|&\n' for char in word):
|
|
160
|
+
if group:
|
|
161
|
+
groups.append(group)
|
|
162
|
+
group = []
|
|
163
|
+
else:
|
|
164
|
+
group.append(word)
|
|
165
|
+
for group in groups:
|
|
166
|
+
executable = posixpath.basename(group[0])
|
|
167
|
+
if re.fullmatch(r'python(?:\d+(?:\.\d+)?)?', executable):
|
|
168
|
+
if '-c' in group and group.index('-c') + 1 < len(group):
|
|
169
|
+
python(group[group.index('-c') + 1])
|
|
170
|
+
else:
|
|
171
|
+
for word in group[1:]:
|
|
172
|
+
if word.endswith('.py'):
|
|
173
|
+
add(word)
|
|
174
|
+
captured = scripts.get(posixpath.normpath(word))
|
|
175
|
+
if captured is not None:
|
|
176
|
+
python(captured)
|
|
177
|
+
for index, word in enumerate(group):
|
|
178
|
+
if word in ('>', '>>') and index + 1 < len(group):
|
|
179
|
+
add(group[index + 1], 'write', True)
|
|
180
|
+
elif index > 0:
|
|
181
|
+
access = 'write' if executable in ('tee', 'touch', 'rm', 'mv') or executable == 'sed' and any(w.startswith('-i') for w in group) else 'read'
|
|
182
|
+
if executable in ('cp', 'install') and index == len(group) - 1:
|
|
183
|
+
access = 'write'
|
|
184
|
+
add(word, access)
|
|
185
|
+
def add_action(path):
|
|
186
|
+
if not isinstance(path, str) or not path:
|
|
187
|
+
return
|
|
188
|
+
canonical = posixpath.normpath(posixpath.join(cwd, path))
|
|
189
|
+
if canonical in files:
|
|
190
|
+
return
|
|
191
|
+
# Providers might report only a suffix for a fully parsed operand.
|
|
192
|
+
# Resolve that suffix only when it identifies exactly one file.
|
|
193
|
+
matches = [known for known in files if known.endswith('/' + posixpath.normpath(path))]
|
|
194
|
+
if not posixpath.isabs(path) and len(matches) == 1:
|
|
195
|
+
return
|
|
196
|
+
add(path, 'read', True)
|
|
197
|
+
|
|
198
|
+
for action in actions or ():
|
|
199
|
+
if isinstance(action, dict):
|
|
200
|
+
if action.get('path'):
|
|
201
|
+
add_action(action['path'])
|
|
202
|
+
for path in action.get('paths') or []:
|
|
203
|
+
add_action(path)
|
|
204
|
+
return source, files
|
meltygui/chat/images.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Inline images for the chat transcript: decode off the render thread, upload
|
|
2
|
+
on it, draw fitted, HDR intact.
|
|
3
|
+
|
|
4
|
+
An `ImageReference` in a message names its picture one of three ways —
|
|
5
|
+
``path`` (a file), ``data`` + ``media_type`` (a base64 payload, the Claude API
|
|
6
|
+
shape, also accepted nested under ``source``), or ``url`` (a data URL) — and
|
|
7
|
+
`image_key` reduces any of them to one cache key. `ImageCache.entry(ref)`
|
|
8
|
+
returns the picture's state: it queues a decode the first time (a worker
|
|
9
|
+
thread running meltygui's `image_load`, hdr-viewer's decoder: PQ PNGs and PQ
|
|
10
|
+
ICC profiles come out as linear scRGB above 1.0, everything else as sRGB8),
|
|
11
|
+
uploads the decoded pixels the first time the render thread asks (GL is
|
|
12
|
+
current inside a window body only), and is drawn with `draw_image` through
|
|
13
|
+
imgui's draw list — an RGB16F texture in meltygui's fp16 scene, so on an HDR
|
|
14
|
+
desktop the highlights present as HDR with nothing more to do, and on an
|
|
15
|
+
SDR desktop they clip at white like everything else meltygui draws.
|
|
16
|
+
|
|
17
|
+
Textures are few and small compared to the payloads: the cache keeps
|
|
18
|
+
``keep`` of them and drops the least recently drawn beyond that.
|
|
19
|
+
"""
|
|
20
|
+
import base64
|
|
21
|
+
import hashlib
|
|
22
|
+
import os
|
|
23
|
+
import queue
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import weakref
|
|
27
|
+
|
|
28
|
+
from meltygui.chat.messages import ImageReference
|
|
29
|
+
|
|
30
|
+
# The SDR reference white a PQ file is authored against (BT.2408: 203 nits);
|
|
31
|
+
# dividing by it makes 1.0 = the file's SDR white = what meltygui maps to the
|
|
32
|
+
# desktop's SDR white (the same mapping Chrome applies).
|
|
33
|
+
PQ_SDR_WHITE = 203.0
|
|
34
|
+
MEDIA_TYPES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _source(ref):
|
|
38
|
+
"""(path, bytes-or-None) of an ImageReference, or (None, None) when it
|
|
39
|
+
carries nothing decodable. Data URLs and base64 payloads are decoded here."""
|
|
40
|
+
source = ref.get("source") if isinstance(ref.get("source"), dict) else ref
|
|
41
|
+
path = source.get("path") or ref.get("path") or source.get("savedPath")
|
|
42
|
+
if path and not str(source.get("data") or "").strip():
|
|
43
|
+
return str(path), None
|
|
44
|
+
data = source.get("data")
|
|
45
|
+
url = source.get("url") or ref.get("url") or ref.get("image_url")
|
|
46
|
+
if isinstance(url, dict):
|
|
47
|
+
url = url.get("url")
|
|
48
|
+
if not data and isinstance(url, str) and url.startswith("data:"):
|
|
49
|
+
data = url.split(",", 1)[1] if "," in url else ""
|
|
50
|
+
if isinstance(data, str) and data.strip():
|
|
51
|
+
try:
|
|
52
|
+
return None, base64.b64decode(data, validate=False)
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
return None, None
|
|
55
|
+
if isinstance(url, str) and url and not url.startswith("data:") and os.path.exists(url):
|
|
56
|
+
return url, None
|
|
57
|
+
return None, None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def image_key(ref):
|
|
61
|
+
"""One stable key per picture, kept on the reference after the first call
|
|
62
|
+
(a payload is hashed once, never per frame). None: nothing to show."""
|
|
63
|
+
key = getattr(ref, "_image_key", False)
|
|
64
|
+
if key is not False:
|
|
65
|
+
return key
|
|
66
|
+
path, data = _source(ref)
|
|
67
|
+
if data is not None:
|
|
68
|
+
key = "data:" + hashlib.sha1(data).hexdigest()
|
|
69
|
+
elif path is not None:
|
|
70
|
+
try:
|
|
71
|
+
stat = os.stat(path)
|
|
72
|
+
key = f"path:{path}:{stat.st_mtime_ns}:{stat.st_size}"
|
|
73
|
+
except OSError:
|
|
74
|
+
key = "path:" + path
|
|
75
|
+
else:
|
|
76
|
+
key = None
|
|
77
|
+
try:
|
|
78
|
+
ref._image_key = key
|
|
79
|
+
except AttributeError:
|
|
80
|
+
pass
|
|
81
|
+
return key
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Entry:
|
|
85
|
+
"""One picture. ``status``: loading → ready | failed. ``size`` (w, h)
|
|
86
|
+
arrives with the decode (the layout can fit the row before the upload),
|
|
87
|
+
``texture`` with the first draw. ``hdr``: the source was PQ."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, key):
|
|
90
|
+
self.key = key
|
|
91
|
+
self.status = "loading"
|
|
92
|
+
self.size = None
|
|
93
|
+
self.texture = None
|
|
94
|
+
self.hdr = False
|
|
95
|
+
self.peak_nits = 0.0
|
|
96
|
+
self.error = None
|
|
97
|
+
self.decoded = None # image_load.Loaded, until uploaded
|
|
98
|
+
self.last_drawn = time.monotonic()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ImageCache:
|
|
102
|
+
def __init__(self, keep=48, wake=None):
|
|
103
|
+
self.entries = {}
|
|
104
|
+
self.keep = keep
|
|
105
|
+
self.wake = wake # called on the worker thread every decode: ask for a frame
|
|
106
|
+
self.jobs = queue.Queue()
|
|
107
|
+
self.worker = None
|
|
108
|
+
self.lock = threading.Lock()
|
|
109
|
+
self.views = weakref.WeakValueDictionary()
|
|
110
|
+
self.generation = 0 # bumped when a decode finishes, layouts keyed on it re-fit
|
|
111
|
+
|
|
112
|
+
def watch(self, draw_state):
|
|
113
|
+
if callable(getattr(draw_state, "invalidate_up", None)):
|
|
114
|
+
self.views[id(draw_state)] = draw_state
|
|
115
|
+
|
|
116
|
+
def decoded_changed(self):
|
|
117
|
+
self.generation += 1
|
|
118
|
+
for view in list(self.views.values()):
|
|
119
|
+
view.invalidate_up()
|
|
120
|
+
if self.wake is not None:
|
|
121
|
+
self.wake()
|
|
122
|
+
|
|
123
|
+
def entry(self, ref):
|
|
124
|
+
key = image_key(ref)
|
|
125
|
+
if key is None:
|
|
126
|
+
return None
|
|
127
|
+
entry = self.entries.get(key)
|
|
128
|
+
if entry is None:
|
|
129
|
+
entry = self.entries[key] = Entry(key)
|
|
130
|
+
self.jobs.put((entry, ref))
|
|
131
|
+
self._ensure_worker()
|
|
132
|
+
return entry
|
|
133
|
+
|
|
134
|
+
def _ensure_worker(self):
|
|
135
|
+
if self.worker is None or not self.worker.is_alive():
|
|
136
|
+
self.worker = threading.Thread(target=self._work, daemon=True, name="chat-images")
|
|
137
|
+
self.worker.start()
|
|
138
|
+
|
|
139
|
+
def _work(self):
|
|
140
|
+
while True:
|
|
141
|
+
try:
|
|
142
|
+
entry, ref = self.jobs.get(timeout=30)
|
|
143
|
+
except queue.Empty:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
import meltygui.image_load as image_load
|
|
147
|
+
path, data = _source(ref)
|
|
148
|
+
loaded = (image_load.load_bytes(data, PQ_SDR_WHITE) if data is not None
|
|
149
|
+
else image_load.load(path, PQ_SDR_WHITE))
|
|
150
|
+
with self.lock:
|
|
151
|
+
entry.decoded, entry.size = loaded, loaded.size
|
|
152
|
+
entry.hdr, entry.peak_nits = loaded.hdr, loaded.peak_nits
|
|
153
|
+
entry.status = "ready"
|
|
154
|
+
except Exception as error:
|
|
155
|
+
entry.error = f"{type(error).__name__}: {error}"
|
|
156
|
+
entry.status = "failed"
|
|
157
|
+
self.decoded_changed()
|
|
158
|
+
|
|
159
|
+
# -- render-only -------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def texture(self, entry):
|
|
162
|
+
"""The GL texture of a decoded entry, uploaded on first use (call
|
|
163
|
+
inside a window body: GL is current there). None until decoded."""
|
|
164
|
+
if entry.texture is None and entry.decoded is not None:
|
|
165
|
+
with self.lock:
|
|
166
|
+
loaded, entry.decoded = entry.decoded, None
|
|
167
|
+
entry.texture = upload_texture(loaded)
|
|
168
|
+
self._evict()
|
|
169
|
+
entry.last_drawn = time.monotonic()
|
|
170
|
+
return entry.texture
|
|
171
|
+
|
|
172
|
+
def _evict(self):
|
|
173
|
+
uploaded = [e for e in self.entries.values() if e.texture is not None]
|
|
174
|
+
if len(uploaded) <= self.keep:
|
|
175
|
+
return
|
|
176
|
+
import OpenGL.GL as gl
|
|
177
|
+
for entry in sorted(uploaded, key=lambda e: e.last_drawn)[:len(uploaded) - self.keep]:
|
|
178
|
+
gl.glDeleteTextures(1, [entry.texture])
|
|
179
|
+
del self.entries[entry.key]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def upload_texture(image):
|
|
183
|
+
"""image_load.Loaded -> GL texture id, row 0 of the array at v = 0 (draw
|
|
184
|
+
with uv (0, 0) → (1, 1) for the top of the picture at the top). Linear
|
|
185
|
+
float goes up as RGB16F, an opaque 8-bit sRGB source as SRGB8 (the GPU
|
|
186
|
+
linearises it on sample)."""
|
|
187
|
+
import numpy as np
|
|
188
|
+
import OpenGL.GL as gl
|
|
189
|
+
w, h = image.size
|
|
190
|
+
tex = int(gl.glGenTextures(1))
|
|
191
|
+
previous = gl.glGetIntegerv(gl.GL_TEXTURE_BINDING_2D)
|
|
192
|
+
gl.glBindTexture(gl.GL_TEXTURE_2D, tex)
|
|
193
|
+
gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1)
|
|
194
|
+
if image.srgb8 is not None:
|
|
195
|
+
gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_SRGB8, w, h, 0, gl.GL_RGB, gl.GL_UNSIGNED_BYTE,
|
|
196
|
+
np.ascontiguousarray(image.srgb8))
|
|
197
|
+
else:
|
|
198
|
+
gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGB16F, w, h, 0, gl.GL_RGB, gl.GL_FLOAT,
|
|
199
|
+
np.ascontiguousarray(image.rgb, dtype=np.float32))
|
|
200
|
+
for p, v in ((gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR), (gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR),
|
|
201
|
+
(gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_EDGE), (gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_EDGE)):
|
|
202
|
+
gl.glTexParameteri(gl.GL_TEXTURE_2D, p, v)
|
|
203
|
+
gl.glBindTexture(gl.GL_TEXTURE_2D, int(previous))
|
|
204
|
+
return tex
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def fitted_size(size, max_width, max_height):
|
|
208
|
+
"""The picture's (w, h) inside the box, never upscaled."""
|
|
209
|
+
w, h = size
|
|
210
|
+
if w <= 0 or h <= 0:
|
|
211
|
+
return max_width, max_height
|
|
212
|
+
scale = min(1.0, max_width / w, max_height / h)
|
|
213
|
+
return max(1.0, w * scale), max(1.0, h * scale)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def image_label(ref, entry=None):
|
|
217
|
+
"""The caption under a picture: its name, size and HDR peak when known."""
|
|
218
|
+
name = ref.get("name") or ref.get("path") or ""
|
|
219
|
+
if isinstance(ref.get("source"), dict):
|
|
220
|
+
name = name or ref["source"].get("path") or ""
|
|
221
|
+
name = os.path.basename(str(name)) if name else ""
|
|
222
|
+
parts = [part for part in (name,) if part]
|
|
223
|
+
if entry is not None and entry.size:
|
|
224
|
+
parts.append("%d×%d" % entry.size)
|
|
225
|
+
if entry.hdr:
|
|
226
|
+
parts.append(f"HDR, peak {entry.peak_nits:.0f} nits")
|
|
227
|
+
return " · ".join(parts)
|