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,864 @@
|
|
|
1
|
+
"""Where is the studio's window on the screen? — the GNOME extension's feed.
|
|
2
|
+
|
|
3
|
+
A Wayland client never learns its own screen position. The GNOME Shell
|
|
4
|
+
extension shipped in gl_gui/gnome_extension (installed by
|
|
5
|
+
installation_helper.py) publishes every window's frame rect and the
|
|
6
|
+
monitors' work areas on the session bus, org.latentdescent.WindowGeometry.
|
|
7
|
+
This module is the studio's reader: one background thread owns a
|
|
8
|
+
GDBusConnection through libgio (ctypes — the venv has no D-Bus library),
|
|
9
|
+
subscribes to the extension's signals for OUR pid (Watch), and keeps the
|
|
10
|
+
latest frame rect + work area in _STATE for the render thread to read
|
|
11
|
+
(`frame_rect()`, `workarea()`). The OS-edge physics (gl_gui/os_frame.py)
|
|
12
|
+
is the consumer: with the position known, the screen edges are honest
|
|
13
|
+
collision walls for the studio's own window edges.
|
|
14
|
+
|
|
15
|
+
Coordinates are the compositor's LOGICAL pixels. The frame rect is the xdg
|
|
16
|
+
window geometry — the studio sets that to its content rect (titlebar
|
|
17
|
+
.sync_window_geometry), so `frame_rect()` is the content's screen rect.
|
|
18
|
+
|
|
19
|
+
Unavailable (no extension, X11, no bus): `available()` is False and every
|
|
20
|
+
reader returns None; the thread retries the subscription every
|
|
21
|
+
RETRY_SECONDS. Nothing here ever blocks the render thread.
|
|
22
|
+
|
|
23
|
+
Hyprland backend (`backend() == "hyprland"`, picked when
|
|
24
|
+
HYPRLAND_INSTANCE_SIGNATURE names a live socket): no extension at all —
|
|
25
|
+
Hyprland's request socket ($XDG_RUNTIME_DIR/hypr/<sig>/.socket.sock, the
|
|
26
|
+
one `hyprctl` speaks) answers `j/clients` / `j/monitors` in ~0.03 ms with
|
|
27
|
+
every window's pid, class, position and size, so the same thread POLLS it
|
|
28
|
+
at Toggles.Melty.hyprland_feed_poll_hz (its event socket has no per-pixel
|
|
29
|
+
move / resize event for floating windows: `movewindow` there is a
|
|
30
|
+
workspace move). Two semantic differences from GNOME, both handled here:
|
|
31
|
+
Hyprland renders the WHOLE surface at `at` and ignores the xdg window
|
|
32
|
+
geometry (Renderer.cpp offsets popups only), so `at`/`size` are the
|
|
33
|
+
SURFACE rect and `frame_rect(inset=…)` shrinks it by the shadow margin to
|
|
34
|
+
get the content; and it ignores a toplevel's buffer offset, so a
|
|
35
|
+
client-side move goes through `hypr_set_box` / `hypr_move_window` over
|
|
36
|
+
the same socket (titlebar.apply_pending_surface_size) instead of
|
|
37
|
+
wayland_move.set_surface_offset. The 0.56 Lua config manager parses
|
|
38
|
+
`dispatch` as Lua (`hl.dsp.window.move{…}`; the classic text is a syntax
|
|
39
|
+
error) — `hypr_config_is_lua` probes which, and `hypr_set_box` folds the
|
|
40
|
+
resize + move into one `eval` anchored at the top-left, since Hyprland's
|
|
41
|
+
floating resize is centred.
|
|
42
|
+
"""
|
|
43
|
+
import ctypes
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import socket
|
|
48
|
+
import sys
|
|
49
|
+
import threading
|
|
50
|
+
import time
|
|
51
|
+
|
|
52
|
+
BUS_NAME = "org.latentdescent.WindowGeometry"
|
|
53
|
+
OBJECT_PATH = "/org/latentdescent/WindowGeometry"
|
|
54
|
+
INTERFACE = "org.latentdescent.WindowGeometry"
|
|
55
|
+
# The studio's app id (glfw WAYLAND_APP_ID / X11 class in lsd_studio.py);
|
|
56
|
+
# the launcher shares our pid, so the class picks the studio's window.
|
|
57
|
+
WM_CLASS = "lsd-studio"
|
|
58
|
+
RETRY_SECONDS = 5.0
|
|
59
|
+
CALL_TIMEOUT_MS = 2000
|
|
60
|
+
|
|
61
|
+
# Survives hotswap (module re-exec reuses the existing dict).
|
|
62
|
+
_STATE = globals().get("_STATE") or {
|
|
63
|
+
"thread": None, "running": False, "available": False, "error": None,
|
|
64
|
+
"pid": None, "frame": None, "monitors": None, "workarea": None,
|
|
65
|
+
"updates": 0, "loop": None, "backend": None, "gen": 0, "lua": None,
|
|
66
|
+
"geometry": None,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_G_BUS_TYPE_SESSION = 2
|
|
70
|
+
|
|
71
|
+
# Hyprland: the request socket hyprctl speaks is named by the instance
|
|
72
|
+
# signature every client of the session inherits.
|
|
73
|
+
HYPR_SIGNATURE_ENV = "HYPRLAND_INSTANCE_SIGNATURE"
|
|
74
|
+
HYPR_REQUEST_TIMEOUT_S = 0.25
|
|
75
|
+
HYPR_MONITORS_EVERY_S = 1.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# The resolved socket is remembered for HYPR_SOCKET_RECHECK_S, so the
|
|
79
|
+
# per-frame backend() reads cost one dict lookup, not a connect.
|
|
80
|
+
HYPR_SOCKET_RECHECK_S = 2.0
|
|
81
|
+
_socket_resolution = globals().get("_socket_resolution") # ((signature, hypr dir), path, checked_at)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _hypr_runtime_dir():
|
|
85
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
|
86
|
+
return os.path.join(runtime, "hypr")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _socket_accepts(path, timeout=0.25):
|
|
90
|
+
"""Is a Hyprland listening on this unix socket? A stale socket FILE
|
|
91
|
+
stays behind when an instance dies without cleaning up. One cheap
|
|
92
|
+
`version` request (a bare connect-and-close makes the peer's reply
|
|
93
|
+
fail on a broken pipe)."""
|
|
94
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
95
|
+
sock.settimeout(timeout)
|
|
96
|
+
try:
|
|
97
|
+
sock.connect(path)
|
|
98
|
+
sock.sendall(b"version")
|
|
99
|
+
sock.recv(4096)
|
|
100
|
+
return True
|
|
101
|
+
except OSError:
|
|
102
|
+
return False
|
|
103
|
+
finally:
|
|
104
|
+
sock.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def hyprland_socket_path():
|
|
108
|
+
"""The Hyprland request socket of the LIVE session, or None outside one.
|
|
109
|
+
HYPRLAND_INSTANCE_SIGNATURE is the first candidate, but a process
|
|
110
|
+
started from a shell that outlived a relogin (the launcher's tmux
|
|
111
|
+
pane, 09-10) inherits the DEAD instance's signature — and that
|
|
112
|
+
instance's socket file can still be there (a crash leaves it behind), so
|
|
113
|
+
existence proves nothing: the env's socket wins if it accepts a
|
|
114
|
+
connection, else the newest instance directory whose socket does, else
|
|
115
|
+
the env's path as it is (unavailable, retried by the feed thread — a
|
|
116
|
+
Hyprland mid-restart). Re-resolved every HYPR_SOCKET_RECHECK_S and
|
|
117
|
+
whenever a request fails (`forget_socket`)."""
|
|
118
|
+
global _socket_resolution
|
|
119
|
+
signature = os.environ.get(HYPR_SIGNATURE_ENV)
|
|
120
|
+
if not signature:
|
|
121
|
+
return None
|
|
122
|
+
now = time.monotonic()
|
|
123
|
+
hypr_dir = _hypr_runtime_dir()
|
|
124
|
+
cached = _socket_resolution
|
|
125
|
+
if cached and cached[0] == (signature, hypr_dir) and now - cached[2] < HYPR_SOCKET_RECHECK_S:
|
|
126
|
+
return cached[1]
|
|
127
|
+
env_path = os.path.join(hypr_dir, signature, ".socket.sock")
|
|
128
|
+
path = None
|
|
129
|
+
if os.path.exists(env_path) and _socket_accepts(env_path):
|
|
130
|
+
path = env_path
|
|
131
|
+
else:
|
|
132
|
+
try:
|
|
133
|
+
others = [d for d in os.listdir(hypr_dir) if d != signature
|
|
134
|
+
and os.path.exists(os.path.join(hypr_dir, d, ".socket.sock"))]
|
|
135
|
+
except OSError:
|
|
136
|
+
others = []
|
|
137
|
+
others.sort(key=lambda d: os.path.getmtime(os.path.join(hypr_dir, d)), reverse=True)
|
|
138
|
+
for other in others:
|
|
139
|
+
candidate = os.path.join(hypr_dir, other, ".socket.sock")
|
|
140
|
+
if _socket_accepts(candidate):
|
|
141
|
+
path = candidate
|
|
142
|
+
break
|
|
143
|
+
if path is None and os.path.exists(env_path):
|
|
144
|
+
path = env_path
|
|
145
|
+
_socket_resolution = ((signature, hypr_dir), path, now)
|
|
146
|
+
return path
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def forget_socket():
|
|
150
|
+
"""Drop the memoized socket so the next lookup rescans the instances."""
|
|
151
|
+
global _socket_resolution
|
|
152
|
+
_socket_resolution = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def backend():
|
|
156
|
+
""""hyprland" when this process runs under a Hyprland session whose
|
|
157
|
+
request socket exists, else "gnome" (the extension's D-Bus feed). None
|
|
158
|
+
off Linux: no compositor feed exists there, so rects stay unavailable."""
|
|
159
|
+
if not sys.platform.startswith("linux"):
|
|
160
|
+
return None
|
|
161
|
+
path = hyprland_socket_path()
|
|
162
|
+
if path and os.path.exists(path):
|
|
163
|
+
return "hyprland"
|
|
164
|
+
return "gnome"
|
|
165
|
+
|
|
166
|
+
_lib = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _GError(ctypes.Structure):
|
|
170
|
+
_fields_ = [("domain", ctypes.c_uint32), ("code", ctypes.c_int), ("message", ctypes.c_char_p)]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_SIGNAL_CB = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p,
|
|
174
|
+
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_void_p)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _libs():
|
|
178
|
+
"""(gio, glib) with the handful of signatures this module calls."""
|
|
179
|
+
global _lib
|
|
180
|
+
if _lib is not None:
|
|
181
|
+
return _lib
|
|
182
|
+
gio = ctypes.CDLL("libgio-2.0.so.0")
|
|
183
|
+
glib = ctypes.CDLL("libglib-2.0.so.0")
|
|
184
|
+
P = ctypes.c_void_p
|
|
185
|
+
gio.g_bus_get_sync.argtypes = [ctypes.c_int, P, ctypes.POINTER(ctypes.POINTER(_GError))]
|
|
186
|
+
gio.g_bus_get_sync.restype = P
|
|
187
|
+
gio.g_dbus_connection_call_sync.argtypes = [P, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
|
188
|
+
ctypes.c_char_p, P, P, ctypes.c_int, ctypes.c_int, P,
|
|
189
|
+
ctypes.POINTER(ctypes.POINTER(_GError))]
|
|
190
|
+
gio.g_dbus_connection_call_sync.restype = P
|
|
191
|
+
gio.g_dbus_connection_signal_subscribe.argtypes = [P, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p,
|
|
192
|
+
ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int,
|
|
193
|
+
_SIGNAL_CB, P, P]
|
|
194
|
+
gio.g_dbus_connection_signal_subscribe.restype = ctypes.c_uint
|
|
195
|
+
gio.g_dbus_connection_signal_unsubscribe.argtypes = [P, ctypes.c_uint]
|
|
196
|
+
gio.g_dbus_connection_signal_unsubscribe.restype = None
|
|
197
|
+
glib.g_variant_parse.argtypes = [P, ctypes.c_char_p, ctypes.c_char_p, P,
|
|
198
|
+
ctypes.POINTER(ctypes.POINTER(_GError))]
|
|
199
|
+
glib.g_variant_parse.restype = P
|
|
200
|
+
glib.g_variant_type_new.argtypes = [ctypes.c_char_p]
|
|
201
|
+
glib.g_variant_type_new.restype = P
|
|
202
|
+
glib.g_variant_type_free.argtypes = [P]
|
|
203
|
+
glib.g_variant_print.argtypes = [P, ctypes.c_int]
|
|
204
|
+
glib.g_variant_print.restype = P # char* - must g_free
|
|
205
|
+
glib.g_variant_unref.argtypes = [P]
|
|
206
|
+
glib.g_free.argtypes = [P]
|
|
207
|
+
glib.g_error_free.argtypes = [P]
|
|
208
|
+
glib.g_main_context_new.restype = P
|
|
209
|
+
glib.g_main_context_push_thread_default.argtypes = [P]
|
|
210
|
+
glib.g_main_context_pop_thread_default.argtypes = [P]
|
|
211
|
+
glib.g_main_context_unref.argtypes = [P]
|
|
212
|
+
glib.g_main_loop_new.argtypes = [P, ctypes.c_int]
|
|
213
|
+
glib.g_main_loop_new.restype = P
|
|
214
|
+
glib.g_main_loop_run.argtypes = [P]
|
|
215
|
+
glib.g_main_loop_quit.argtypes = [P]
|
|
216
|
+
glib.g_main_loop_unref.argtypes = [P]
|
|
217
|
+
_lib = (gio, glib)
|
|
218
|
+
return _lib
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# GVariant text ↔ Python - the a{sv} dicts the extension speaks
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def parse_variant_dicts(text):
|
|
226
|
+
"""The a{sv} dicts of a GVariant text form as Python dicts (ints,
|
|
227
|
+
floats, bools and strings — everything GetWindows / GetMonitors carry)."""
|
|
228
|
+
dicts = []
|
|
229
|
+
for chunk in re.findall(r"\{([^{}]*)\}", text or ""):
|
|
230
|
+
entry = {}
|
|
231
|
+
for key, raw in re.findall(r"'(\w+)':\s*<([^>]*)>", chunk):
|
|
232
|
+
raw = raw.strip()
|
|
233
|
+
# a typed literal: <uint64 42>, <int32 -3>, <double 1.5>
|
|
234
|
+
match = re.fullmatch(r"(?:u?int(?:16|32|64)|byte|double)\s+(\S+)", raw)
|
|
235
|
+
if match:
|
|
236
|
+
raw = match.group(1)
|
|
237
|
+
if raw in ("true", "false"):
|
|
238
|
+
entry[key] = raw == "true"
|
|
239
|
+
elif re.fullmatch(r"-?\d+", raw):
|
|
240
|
+
entry[key] = int(raw)
|
|
241
|
+
elif re.fullmatch(r"-?\d+\.\d*(?:e[-+]?\d+)?", raw):
|
|
242
|
+
entry[key] = float(raw)
|
|
243
|
+
else:
|
|
244
|
+
entry[key] = raw.strip("'\"")
|
|
245
|
+
if entry:
|
|
246
|
+
dicts.append(entry)
|
|
247
|
+
return dicts
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def pick_window(windows, pid, wm_class=WM_CLASS):
|
|
251
|
+
"""Our window among the feed's: the pid's window of our class, else the
|
|
252
|
+
pid's first window."""
|
|
253
|
+
mine = [w for w in windows or () if w.get("pid") == pid]
|
|
254
|
+
for w in mine:
|
|
255
|
+
if w.get("wm_class") == wm_class:
|
|
256
|
+
return w
|
|
257
|
+
return mine[0] if mine else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Hyprland: poll the request socket
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def hypr_request(command, path=None, timeout=HYPR_REQUEST_TIMEOUT_S):
|
|
265
|
+
"""One request on Hyprland's socket (a fresh connection per request,
|
|
266
|
+
exactly like hyprctl): the reply text. `j/…` replies are JSON."""
|
|
267
|
+
path = path or hyprland_socket_path()
|
|
268
|
+
if not path:
|
|
269
|
+
raise RuntimeError("not a Hyprland session")
|
|
270
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
271
|
+
sock.settimeout(timeout)
|
|
272
|
+
try:
|
|
273
|
+
sock.connect(path)
|
|
274
|
+
sock.sendall(command.encode())
|
|
275
|
+
chunks = []
|
|
276
|
+
while True:
|
|
277
|
+
chunk = sock.recv(65536)
|
|
278
|
+
if not chunk:
|
|
279
|
+
break
|
|
280
|
+
chunks.append(chunk)
|
|
281
|
+
finally:
|
|
282
|
+
sock.close()
|
|
283
|
+
return b"".join(chunks).decode("utf-8", "replace")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def hyprland_window_info(client):
|
|
287
|
+
"""A `j/clients` entry as the feed's window dict (the extension's keys,
|
|
288
|
+
so os_frame / pick_window read both backends alike). x/y/width/height
|
|
289
|
+
are the SURFACE rect — Hyprland places the surface, not the xdg
|
|
290
|
+
geometry — in logical pixels; `address` is Hyprland's window handle
|
|
291
|
+
(the dispatchers' `address:0x…` selector)."""
|
|
292
|
+
at = client.get("at") or (0, 0)
|
|
293
|
+
size = client.get("size") or (0, 0)
|
|
294
|
+
address = str(client.get("address") or "0x0")
|
|
295
|
+
fullscreen = client.get("fullscreen") or 0 # 0 none, 1 maximized, 2 fullscreen
|
|
296
|
+
return {
|
|
297
|
+
"id": int(address, 16),
|
|
298
|
+
"address": address,
|
|
299
|
+
"pid": int(client.get("pid") or 0),
|
|
300
|
+
"wm_class": client.get("class") or "",
|
|
301
|
+
"title": client.get("title") or "",
|
|
302
|
+
"x": int(at[0]), "y": int(at[1]),
|
|
303
|
+
"width": int(size[0]), "height": int(size[1]),
|
|
304
|
+
"monitor": int(client.get("monitor") if client.get("monitor") is not None else 0),
|
|
305
|
+
"maximized": fullscreen == 1,
|
|
306
|
+
"fullscreen": fullscreen == 2,
|
|
307
|
+
"focused": client.get("focusHistoryID") == 0,
|
|
308
|
+
"floating": bool(client.get("floating")),
|
|
309
|
+
"mapped": bool(client.get("mapped", True)),
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def hyprland_monitor_info(monitor):
|
|
314
|
+
"""A `j/monitors` entry as the feed's monitor dict. Hyprland reports
|
|
315
|
+
the mode in PHYSICAL pixels; the layout (window `at`/`size`) is
|
|
316
|
+
logical, so the size is divided by the scale and swapped for a 90°
|
|
317
|
+
transform. `reserved` = [left, top, right, bottom] px kept for
|
|
318
|
+
bars (waybar's dock) — the work area is the geometry less those."""
|
|
319
|
+
scale = float(monitor.get("scale") or 1.0)
|
|
320
|
+
width = float(monitor.get("width") or 0) / scale
|
|
321
|
+
height = float(monitor.get("height") or 0) / scale
|
|
322
|
+
if int(monitor.get("transform") or 0) % 2:
|
|
323
|
+
width, height = height, width
|
|
324
|
+
left, top, right, bottom = (list(monitor.get("reserved") or [0, 0, 0, 0]) + [0, 0, 0, 0])[:4]
|
|
325
|
+
x, y = int(monitor.get("x") or 0), int(monitor.get("y") or 0)
|
|
326
|
+
return {
|
|
327
|
+
"index": int(monitor.get("id") or 0),
|
|
328
|
+
"name": monitor.get("name") or "",
|
|
329
|
+
"x": x, "y": y,
|
|
330
|
+
"width": int(round(width)), "height": int(round(height)),
|
|
331
|
+
"work_x": x + int(left), "work_y": y + int(top),
|
|
332
|
+
"work_width": int(round(width)) - int(left) - int(right),
|
|
333
|
+
"work_height": int(round(height)) - int(top) - int(bottom),
|
|
334
|
+
"scale": scale,
|
|
335
|
+
"primary": bool(monitor.get("focused")),
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _hypr_poll_windows(pid, path):
|
|
340
|
+
"""One `j/clients` poll: our window into _STATE. `updates` moves only
|
|
341
|
+
when the rect (or the window) changed — the count is the consumers'
|
|
342
|
+
change signal, a poll that saw nothing new must not bump it."""
|
|
343
|
+
clients = json.loads(hypr_request("j/clients", path))
|
|
344
|
+
infos = [hyprland_window_info(c) for c in clients]
|
|
345
|
+
# Every window of ours (app.py surfaces: several per process), for the
|
|
346
|
+
# by-title lookups (surface_rect / place_window / _current_frame).
|
|
347
|
+
_STATE["windows"] = [w for w in infos if w.get("pid") == pid]
|
|
348
|
+
win = pick_window(infos, pid)
|
|
349
|
+
cur = _STATE["frame"]
|
|
350
|
+
if win is None:
|
|
351
|
+
if cur is not None:
|
|
352
|
+
_STATE["frame"] = None
|
|
353
|
+
_STATE["updates"] += 1
|
|
354
|
+
return None
|
|
355
|
+
if cur is None or any(win[k] != cur.get(k) for k in ("id", "x", "y", "width", "height", "monitor",
|
|
356
|
+
"maximized", "fullscreen")):
|
|
357
|
+
_STATE["frame"] = win
|
|
358
|
+
_STATE["updates"] += 1
|
|
359
|
+
_refresh_workarea()
|
|
360
|
+
return win
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _hypr_poll_monitors(path):
|
|
364
|
+
_STATE["monitors"] = [hyprland_monitor_info(m) for m in json.loads(hypr_request("j/monitors", path))]
|
|
365
|
+
_refresh_workarea()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _hypr_poll_interval():
|
|
369
|
+
try:
|
|
370
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
371
|
+
hz = float(Toggles.Melty.hyprland_feed_poll_hz)
|
|
372
|
+
except Exception:
|
|
373
|
+
hz = 120.0
|
|
374
|
+
return 1.0 / max(hz, 1.0)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _alive(gen):
|
|
378
|
+
"""Is the thread of generation ``gen`` still the wanted one? start()
|
|
379
|
+
bumps the generation, so a superseded thread (a hotswap that changed
|
|
380
|
+
the backend, a stop) winds down on its own — the old D-Bus loop kept
|
|
381
|
+
running through the first Hyprland hotswap and start() saw a live
|
|
382
|
+
thread and did nothing (09-06)."""
|
|
383
|
+
return _STATE["running"] and _STATE.get("gen") == gen
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _hyprland_thread_main(pid, gen):
|
|
387
|
+
"""Poll loop: clients every tick, monitors every HYPR_MONITORS_EVERY_S.
|
|
388
|
+
A failed request (Hyprland restarting, socket gone) marks the feed
|
|
389
|
+
unavailable and retries after RETRY_SECONDS."""
|
|
390
|
+
path = hyprland_socket_path()
|
|
391
|
+
monitors_at = 0.0
|
|
392
|
+
while _alive(gen):
|
|
393
|
+
try:
|
|
394
|
+
now = time.monotonic()
|
|
395
|
+
if now - monitors_at >= HYPR_MONITORS_EVERY_S:
|
|
396
|
+
_hypr_poll_monitors(path)
|
|
397
|
+
monitors_at = now
|
|
398
|
+
_hypr_poll_windows(pid, path)
|
|
399
|
+
_STATE["available"] = True
|
|
400
|
+
_STATE["error"] = None
|
|
401
|
+
time.sleep(_hypr_poll_interval())
|
|
402
|
+
except Exception as ex:
|
|
403
|
+
_STATE["available"] = False
|
|
404
|
+
_STATE["error"] = str(ex)
|
|
405
|
+
deadline = time.monotonic() + RETRY_SECONDS
|
|
406
|
+
while _alive(gen) and time.monotonic() < deadline:
|
|
407
|
+
time.sleep(0.25)
|
|
408
|
+
forget_socket()
|
|
409
|
+
path = hyprland_socket_path() # the instance may have restarted under us
|
|
410
|
+
if _STATE.get("gen") == gen:
|
|
411
|
+
_STATE["available"] = False
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _current_frame():
|
|
415
|
+
"""The feed's window dict for the CURRENT window: the studio's picked
|
|
416
|
+
one, or — once app.py surfaces are in use — the active surface's,
|
|
417
|
+
matched by title (several windows share our pid and class)."""
|
|
418
|
+
title = _active_surface_title()
|
|
419
|
+
if title is not None:
|
|
420
|
+
return _window_by_title(title)
|
|
421
|
+
return _STATE["frame"]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _active_surface_title():
|
|
425
|
+
try:
|
|
426
|
+
from meltygui.core.windowing.surface import Surface
|
|
427
|
+
except Exception:
|
|
428
|
+
return None
|
|
429
|
+
active = Surface.active
|
|
430
|
+
return active.title if active is not None else None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _window_by_title(title):
|
|
434
|
+
for w in _STATE.get("windows") or ():
|
|
435
|
+
if w.get("title") == title:
|
|
436
|
+
return w
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def surface_rect(title):
|
|
441
|
+
"""(x, y, width, height) of OUR window titled ``title`` (its surface
|
|
442
|
+
rect, logical px), or None while the feed has not seen it."""
|
|
443
|
+
if not _STATE["available"]:
|
|
444
|
+
return None
|
|
445
|
+
w = _window_by_title(title)
|
|
446
|
+
return (w["x"], w["y"], w["width"], w["height"]) if w else None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def place_window(title, rect, *, resize=True):
|
|
450
|
+
"""Move AND resize our window titled ``title`` to ``rect`` (absolute
|
|
451
|
+
logical px, top-left anchored) in one request — app.py's child
|
|
452
|
+
surfaces following their parent. With resize=False only move: the
|
|
453
|
+
surface's edge solver owns its size. Hyprland only; True on "ok"."""
|
|
454
|
+
if backend() != "hyprland":
|
|
455
|
+
return False
|
|
456
|
+
w = _window_by_title(title)
|
|
457
|
+
if w is None:
|
|
458
|
+
return False
|
|
459
|
+
selector = f"address:{w['address']}"
|
|
460
|
+
x, y, width, height = (int(v) for v in rect)
|
|
461
|
+
if hypr_config_is_lua():
|
|
462
|
+
size_request = (f'hl.dispatch(hl.dsp.window.resize({{x = {width}, y = {height}, window = "{selector}"}})); '
|
|
463
|
+
if resize else '')
|
|
464
|
+
script = (f'local w = hl.get_window("{selector}"); '
|
|
465
|
+
f'if not w then error("no window {selector}") end; '
|
|
466
|
+
f'{size_request}'
|
|
467
|
+
f'hl.dispatch(hl.dsp.window.move({{x = {x}, y = {y}, window = "{selector}"}}))')
|
|
468
|
+
return _hypr_eval(script, "place_window")
|
|
469
|
+
ok = not resize or _hypr_run(f"dispatch resizewindowpixel exact {width} {height},{selector}", "resizewindowpixel")
|
|
470
|
+
return _hypr_run(f"dispatch movewindowpixel exact {x} {y},{selector}", "movewindowpixel") and ok
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _hypr_selector():
|
|
474
|
+
"""Hyprland's window selector for the current window (_current_frame),
|
|
475
|
+
or None while the feed has not seen it."""
|
|
476
|
+
frame = _current_frame()
|
|
477
|
+
if backend() != "hyprland" or frame is None:
|
|
478
|
+
return None
|
|
479
|
+
return f"address:{frame['address']}"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def hypr_honors_geometry():
|
|
483
|
+
"""Does this Hyprland honour xdg_surface.set_window_geometry on
|
|
484
|
+
toplevels (the 09-09 compositor patch, `render:xdg_window_geometry`)?
|
|
485
|
+
Then the feed's `at` / `size` are the CONTENT box — the border, the
|
|
486
|
+
shadow and the hit test hug it — and the surface overhangs it by the
|
|
487
|
+
shadow margin, exactly as on GNOME; `frame_rect` needs no inset and
|
|
488
|
+
`titlebar.sync_window_geometry` sends the content rect. Probed ONCE
|
|
489
|
+
per feed generation with `getoption`: "bool: true" = yes; a stock or
|
|
490
|
+
older binary answers "no such option" (= no) and keeps the
|
|
491
|
+
box-is-the-surface handling."""
|
|
492
|
+
known = _STATE.get("geometry")
|
|
493
|
+
if known is not None:
|
|
494
|
+
return known
|
|
495
|
+
if backend() != "hyprland":
|
|
496
|
+
return False
|
|
497
|
+
try:
|
|
498
|
+
reply = hypr_request("getoption render:xdg_window_geometry")
|
|
499
|
+
except Exception as ex:
|
|
500
|
+
_STATE["error"] = str(ex)
|
|
501
|
+
return False # unknown: try again next time
|
|
502
|
+
first = reply.strip().split("\n", 1)[0].strip()
|
|
503
|
+
_STATE["geometry"] = first.startswith("bool:") and first.split(":", 1)[1].strip() in ("true", "1")
|
|
504
|
+
return _STATE["geometry"]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def hypr_config_is_lua():
|
|
508
|
+
"""Does this Hyprland parse socket `dispatch` requests as LUA (the
|
|
509
|
+
0.56 Lua config manager: `dispatch X` is `eval return hl.dispatch(X)`,
|
|
510
|
+
so the classic `movewindowpixel dx dy,address:…` text is a Lua syntax
|
|
511
|
+
error — every move and resize the studio sent under it was refused
|
|
512
|
+
with "')' expected", which is what kept the window fixed while the UI
|
|
513
|
+
moved, 09-08)? Probed ONCE per feed generation with `eval return true`:
|
|
514
|
+
"ok" = Lua; the hyprlang build answers "eval is only supported with
|
|
515
|
+
the lua config manager"."""
|
|
516
|
+
known = _STATE.get("lua")
|
|
517
|
+
if known is not None:
|
|
518
|
+
return known
|
|
519
|
+
try:
|
|
520
|
+
reply = hypr_request("eval return true").strip()
|
|
521
|
+
except Exception as ex:
|
|
522
|
+
_STATE["error"] = str(ex)
|
|
523
|
+
return False # unknown: try again next time
|
|
524
|
+
_STATE["lua"] = reply == "ok"
|
|
525
|
+
return _STATE["lua"]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _hypr_run(request, what):
|
|
529
|
+
"""One request expected to answer "ok"; the error text lands in
|
|
530
|
+
`last_error()` under ``what`` otherwise."""
|
|
531
|
+
try:
|
|
532
|
+
reply = hypr_request(request)
|
|
533
|
+
except Exception as ex:
|
|
534
|
+
_STATE["error"] = str(ex)
|
|
535
|
+
if os.environ.get("MELTY_DEBUG"):
|
|
536
|
+
print(f"[geometry_feed] {what}: {request!r} -> EXC {ex}", flush=True)
|
|
537
|
+
return False
|
|
538
|
+
ok = reply.strip() == "ok"
|
|
539
|
+
if os.environ.get("MELTY_DEBUG"):
|
|
540
|
+
print(f"[geometry_feed] {what}: {request[:160]!r} -> {reply.strip()[:80]!r}", flush=True)
|
|
541
|
+
if not ok:
|
|
542
|
+
_STATE["error"] = f"{what}: {reply.strip()}"
|
|
543
|
+
return ok
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _hypr_dispatch(command):
|
|
547
|
+
"""Legacy (hyprlang) `dispatch <command>,address:…` on the studio's
|
|
548
|
+
window; True on "ok"."""
|
|
549
|
+
selector = _hypr_selector()
|
|
550
|
+
if selector is None:
|
|
551
|
+
return False
|
|
552
|
+
return _hypr_run(f"dispatch {command},{selector}", command.split()[0])
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _hypr_eval(script, what):
|
|
556
|
+
"""`eval <script>` on the Lua config manager; True on "ok"."""
|
|
557
|
+
return _hypr_run(f"eval {script}", what)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def hypr_set_box(width, height, dx=0, dy=0):
|
|
561
|
+
"""Resize the window's content box and move it by (dx, dy).
|
|
562
|
+
|
|
563
|
+
On the patched Lua compositor, publish the box with the matching
|
|
564
|
+
buffer commit. Until Melty finishes drawing, the old buffer keeps
|
|
565
|
+
its old box; the new buffer appears at its own size, without a
|
|
566
|
+
compositor resize animation.
|
|
567
|
+
|
|
568
|
+
Older builds retain a single-eval fallback: read the goal position,
|
|
569
|
+
resize, then move absolutely to goal + offset. This compensates for
|
|
570
|
+
Hyprland's centred floating resize without using the stale feed.
|
|
571
|
+
The legacy hyprlang fallback leaves that centred half-step intact.
|
|
572
|
+
"""
|
|
573
|
+
width, height, dx, dy = int(width), int(height), int(dx), int(dy)
|
|
574
|
+
selector = _hypr_selector()
|
|
575
|
+
if selector is None:
|
|
576
|
+
return False
|
|
577
|
+
if hypr_config_is_lua():
|
|
578
|
+
prefix = (f'local w = hl.get_window("{selector}"); '
|
|
579
|
+
f'if not w then error("no window {selector}") end; ')
|
|
580
|
+
fallback = ('local p = w.at; '
|
|
581
|
+
f'hl.dispatch(hl.dsp.window.resize({{x = {width}, y = {height}, window = "{selector}"}})); '
|
|
582
|
+
f'hl.dispatch(hl.dsp.window.move({{x = p.x + ({dx}), y = p.y + ({dy}), window = "{selector}"}}))')
|
|
583
|
+
if hypr_honors_geometry():
|
|
584
|
+
# Capability detection lives in the same IPC session. Existing
|
|
585
|
+
# sessions keep working until the new compositor is activated.
|
|
586
|
+
script = (prefix + 'if hl.dsp.window.resize_on_commit and not w.xwayland '
|
|
587
|
+
'and w.floating and not w.group and w.fullscreen == 0 then '
|
|
588
|
+
f'hl.dispatch(hl.dsp.window.resize_on_commit({{width = {width}, height = {height}, '
|
|
589
|
+
f'dx = {dx}, dy = {dy}, window = "{selector}"}})) '
|
|
590
|
+
'else ' + fallback + ' end')
|
|
591
|
+
else:
|
|
592
|
+
script = prefix + fallback
|
|
593
|
+
return _hypr_eval(script, "set_box")
|
|
594
|
+
ok = _hypr_dispatch(f"resizewindowpixel exact {width} {height}")
|
|
595
|
+
if dx or dy:
|
|
596
|
+
ok = _hypr_dispatch(f"movewindowpixel {dx} {dy}") and ok
|
|
597
|
+
return ok
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def hypr_resize_window(width, height):
|
|
601
|
+
"""Resize the studio's window box to (width, height) logical px, its
|
|
602
|
+
top-left held (hypr_set_box)."""
|
|
603
|
+
return hypr_set_box(width, height)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def hypr_move_window(dx, dy):
|
|
607
|
+
"""Move the studio's window by (dx, dy) logical px — the Hyprland
|
|
608
|
+
stand-in for the buffer-offset move (which Hyprland ignores on
|
|
609
|
+
toplevels). Synchronous, ~0.05 ms; True when Hyprland answered "ok".
|
|
610
|
+
The feed shows the move on its next poll, which is what os_frame's
|
|
611
|
+
in-flight bookkeeping waits for."""
|
|
612
|
+
dx, dy = int(dx), int(dy)
|
|
613
|
+
if not (dx or dy):
|
|
614
|
+
return False
|
|
615
|
+
selector = _hypr_selector()
|
|
616
|
+
if selector is None:
|
|
617
|
+
return False
|
|
618
|
+
if hypr_config_is_lua():
|
|
619
|
+
return _hypr_eval(f'hl.dispatch(hl.dsp.window.move({{x = {dx}, y = {dy}, relative = true, '
|
|
620
|
+
f'window = "{selector}"}}))', "move")
|
|
621
|
+
return _hypr_dispatch(f"movewindowpixel {dx} {dy}")
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ---------------------------------------------------------------------------
|
|
625
|
+
# Our bus
|
|
626
|
+
# ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
class _Bus:
|
|
629
|
+
"""One GDBusConnection on the feed thread."""
|
|
630
|
+
|
|
631
|
+
def __init__(self):
|
|
632
|
+
self.gio, self.glib = _libs()
|
|
633
|
+
self.conn = None
|
|
634
|
+
|
|
635
|
+
def connect(self):
|
|
636
|
+
err = ctypes.POINTER(_GError)()
|
|
637
|
+
self.conn = self.gio.g_bus_get_sync(_G_BUS_TYPE_SESSION, None, ctypes.byref(err))
|
|
638
|
+
if not self.conn:
|
|
639
|
+
raise RuntimeError(self._take(err))
|
|
640
|
+
return self.conn
|
|
641
|
+
|
|
642
|
+
def _take(self, err):
|
|
643
|
+
message = "unknown GError"
|
|
644
|
+
if err:
|
|
645
|
+
message = (err.contents.message or b"").decode("utf-8", "replace")
|
|
646
|
+
self.glib.g_error_free(err)
|
|
647
|
+
return message
|
|
648
|
+
|
|
649
|
+
def call(self, method, params_text=None, params_type=None):
|
|
650
|
+
"""Call ``method`` on the extension; returns the reply's text form."""
|
|
651
|
+
params = None
|
|
652
|
+
if params_text is not None:
|
|
653
|
+
err = ctypes.POINTER(_GError)()
|
|
654
|
+
vtype = self.glib.g_variant_type_new(params_type.encode()) if params_type else None
|
|
655
|
+
params = self.glib.g_variant_parse(vtype, params_text.encode(), None, None, ctypes.byref(err))
|
|
656
|
+
if vtype:
|
|
657
|
+
self.glib.g_variant_type_free(vtype)
|
|
658
|
+
if not params:
|
|
659
|
+
raise RuntimeError(f"bad params {params_text!r}: {self._take(err)}")
|
|
660
|
+
err = ctypes.POINTER(_GError)()
|
|
661
|
+
reply = self.gio.g_dbus_connection_call_sync(
|
|
662
|
+
self.conn, BUS_NAME.encode(), OBJECT_PATH.encode(), INTERFACE.encode(), method.encode(),
|
|
663
|
+
params, None, 0, CALL_TIMEOUT_MS, None, ctypes.byref(err))
|
|
664
|
+
if not reply:
|
|
665
|
+
raise RuntimeError(self._take(err))
|
|
666
|
+
try:
|
|
667
|
+
return self.text_of(reply)
|
|
668
|
+
finally:
|
|
669
|
+
self.glib.g_variant_unref(reply)
|
|
670
|
+
|
|
671
|
+
def text_of(self, variant):
|
|
672
|
+
raw = self.glib.g_variant_print(variant, 0)
|
|
673
|
+
try:
|
|
674
|
+
return ctypes.string_at(raw).decode("utf-8", "replace")
|
|
675
|
+
finally:
|
|
676
|
+
self.glib.g_free(raw)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _apply_windows(text, pid):
|
|
680
|
+
win = pick_window(parse_variant_dicts(text), pid)
|
|
681
|
+
if win is not None:
|
|
682
|
+
_STATE["frame"] = win
|
|
683
|
+
_STATE["updates"] += 1
|
|
684
|
+
return win
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _apply_monitors(text):
|
|
688
|
+
monitors = parse_variant_dicts(text)
|
|
689
|
+
_STATE["monitors"] = monitors
|
|
690
|
+
_refresh_workarea()
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _refresh_workarea():
|
|
694
|
+
frame, monitors = _STATE["frame"], _STATE["monitors"]
|
|
695
|
+
if not monitors:
|
|
696
|
+
return
|
|
697
|
+
index = frame.get("monitor", 0) if frame else 0
|
|
698
|
+
mon = next((m for m in monitors if m.get("index") == index), monitors[0])
|
|
699
|
+
_STATE["workarea"] = (mon["work_x"], mon["work_y"], mon["work_width"], mon["work_height"])
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _on_signal(_conn, _sender, _path, _iface, name, params, _data):
|
|
703
|
+
"""Feed-thread callback for the extension's signals."""
|
|
704
|
+
try:
|
|
705
|
+
bus = _STATE.get("bus")
|
|
706
|
+
if bus is None:
|
|
707
|
+
return
|
|
708
|
+
name = (name or b"").decode()
|
|
709
|
+
text = bus.text_of(params) if params else ""
|
|
710
|
+
if name == "Geometry":
|
|
711
|
+
dicts = parse_variant_dicts(text)
|
|
712
|
+
if dicts and dicts[0].get("pid") == _STATE["pid"]:
|
|
713
|
+
win = dicts[0]
|
|
714
|
+
cur = _STATE["frame"]
|
|
715
|
+
# a second window of ours (the launcher) never displaces
|
|
716
|
+
# the studio's
|
|
717
|
+
if cur is None or win.get("id") == cur.get("id") or win.get("wm_class") == WM_CLASS:
|
|
718
|
+
_STATE["frame"] = win
|
|
719
|
+
_STATE["updates"] += 1
|
|
720
|
+
_refresh_workarea()
|
|
721
|
+
elif name == "MonitorsChanged":
|
|
722
|
+
_apply_monitors(bus.call("GetMonitors"))
|
|
723
|
+
elif name == "Removed":
|
|
724
|
+
cur = _STATE["frame"]
|
|
725
|
+
match = re.match(r"\((?:uint64 )?(\d+),", text)
|
|
726
|
+
if cur is not None and match and int(match.group(1)) == cur.get("id"):
|
|
727
|
+
_STATE["frame"] = None
|
|
728
|
+
except Exception as ex: # never let a callback escape into C
|
|
729
|
+
_STATE["error"] = str(ex)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
_signal_cb = _SIGNAL_CB(_on_signal)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _thread_main(pid, gen):
|
|
736
|
+
gio, glib = _libs()
|
|
737
|
+
ctx = glib.g_main_context_new()
|
|
738
|
+
glib.g_main_context_push_thread_default(ctx)
|
|
739
|
+
try:
|
|
740
|
+
while _alive(gen):
|
|
741
|
+
try:
|
|
742
|
+
bus = _Bus()
|
|
743
|
+
bus.connect()
|
|
744
|
+
_STATE["bus"] = bus
|
|
745
|
+
sub = gio.g_dbus_connection_signal_subscribe(
|
|
746
|
+
bus.conn, BUS_NAME.encode(), INTERFACE.encode(), None, OBJECT_PATH.encode(),
|
|
747
|
+
None, 0, _signal_cb, None, None)
|
|
748
|
+
# Watch first (the subscription is live from here), then the
|
|
749
|
+
# snapshot: a move between the two is caught by the signal.
|
|
750
|
+
bus.call("Watch", f"({int(pid)},)", "(i)")
|
|
751
|
+
_apply_monitors(bus.call("GetMonitors"))
|
|
752
|
+
_apply_windows(bus.call("GetWindows", f"({int(pid)},)", "(i)"), pid)
|
|
753
|
+
_STATE["available"] = True
|
|
754
|
+
_STATE["error"] = None
|
|
755
|
+
loop = glib.g_main_loop_new(ctx, 0)
|
|
756
|
+
_STATE["loop"] = loop
|
|
757
|
+
glib.g_main_loop_run(loop)
|
|
758
|
+
_STATE["loop"] = None
|
|
759
|
+
glib.g_main_loop_unref(loop)
|
|
760
|
+
gio.g_dbus_connection_signal_unsubscribe(bus.conn, sub)
|
|
761
|
+
try:
|
|
762
|
+
bus.call("Unwatch", f"({int(pid)},)", "(i)")
|
|
763
|
+
except Exception:
|
|
764
|
+
pass
|
|
765
|
+
return
|
|
766
|
+
except Exception as ex:
|
|
767
|
+
_STATE["available"] = False
|
|
768
|
+
_STATE["error"] = str(ex)
|
|
769
|
+
_STATE["bus"] = None
|
|
770
|
+
deadline = time.monotonic() + RETRY_SECONDS
|
|
771
|
+
while _alive(gen) and time.monotonic() < deadline:
|
|
772
|
+
time.sleep(0.25)
|
|
773
|
+
finally:
|
|
774
|
+
glib.g_main_context_pop_thread_default(ctx)
|
|
775
|
+
glib.g_main_context_unref(ctx)
|
|
776
|
+
if _STATE.get("gen") == gen:
|
|
777
|
+
_STATE["available"] = False
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def start(pid=None):
|
|
781
|
+
"""Start the reader thread for the session's backend (idempotent while
|
|
782
|
+
that thread runs; a live thread of the OTHER backend — a hotswap after
|
|
783
|
+
a desktop switch — is superseded by a new generation)."""
|
|
784
|
+
wanted = backend()
|
|
785
|
+
if wanted is None:
|
|
786
|
+
return None
|
|
787
|
+
thread = _STATE["thread"]
|
|
788
|
+
if thread is not None and thread.is_alive() and _STATE.get("backend") == wanted:
|
|
789
|
+
return thread
|
|
790
|
+
stop()
|
|
791
|
+
if pid is not None:
|
|
792
|
+
_STATE["pid"] = int(pid)
|
|
793
|
+
elif not _STATE.get("pid"):
|
|
794
|
+
_STATE["pid"] = os.getpid() # a restart keeps the pid it was started with
|
|
795
|
+
_STATE["gen"] = _STATE.get("gen", 0) + 1
|
|
796
|
+
_STATE["running"] = True
|
|
797
|
+
_STATE["backend"] = wanted
|
|
798
|
+
_STATE["frame"] = None
|
|
799
|
+
_STATE["lua"] = None # re-probe the dispatching (hypr_config_is_lua)
|
|
800
|
+
_STATE["geometry"] = None # re-probe the geometry support (hypr_honors_geometry)
|
|
801
|
+
_STATE["available"] = False
|
|
802
|
+
target = _hyprland_thread_main if wanted == "hyprland" else _thread_main
|
|
803
|
+
thread = threading.Thread(target=target, args=(_STATE["pid"], _STATE["gen"]),
|
|
804
|
+
name="window-geometry-feed", daemon=True)
|
|
805
|
+
_STATE["thread"] = thread
|
|
806
|
+
thread.start()
|
|
807
|
+
return thread
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def ensure_started():
|
|
811
|
+
"""Per-frame cheap check (os_frame._observe): the feed thread of the
|
|
812
|
+
CURRENT backend is running — starts / restarts it otherwise."""
|
|
813
|
+
wanted = backend()
|
|
814
|
+
if wanted is None:
|
|
815
|
+
return
|
|
816
|
+
thread = _STATE["thread"]
|
|
817
|
+
if thread is None or not thread.is_alive() or _STATE.get("backend") != wanted:
|
|
818
|
+
start()
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def stop():
|
|
822
|
+
_STATE["running"] = False
|
|
823
|
+
_STATE["gen"] = _STATE.get("gen", 0) + 1 # the old thread notices this after a restart
|
|
824
|
+
loop = _STATE.get("loop")
|
|
825
|
+
if loop:
|
|
826
|
+
_libs()[1].g_main_loop_quit(loop)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def available():
|
|
830
|
+
return bool(_STATE["available"] and _STATE["frame"])
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def last_error():
|
|
834
|
+
return _STATE["error"]
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def frame_rect(inset=0):
|
|
838
|
+
"""(x, y, width, height) of the studio's content rect on the screen, or
|
|
839
|
+
None while the feed is unavailable. ``inset`` = the transparent shadow
|
|
840
|
+
margin (titlebar.window_inset) — applied only on a Hyprland that does
|
|
841
|
+
NOT honour the window geometry (its rect is then the SURFACE); the
|
|
842
|
+
GNOME feed's frame rect and a geometry-honouring Hyprland's box are
|
|
843
|
+
the xdg geometry, already the content (hypr_honors_geometry)."""
|
|
844
|
+
frame = _current_frame()
|
|
845
|
+
if not _STATE["available"] or frame is None:
|
|
846
|
+
return None
|
|
847
|
+
x, y, w, h = frame["x"], frame["y"], frame["width"], frame["height"]
|
|
848
|
+
if inset and _STATE.get("backend") == "hyprland" and not hypr_honors_geometry():
|
|
849
|
+
inset = int(inset)
|
|
850
|
+
x, y, w, h = x + inset, y + inset, max(w - 2 * inset, 0), max(h - 2 * inset, 0)
|
|
851
|
+
return (x, y, w, h)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def workarea():
|
|
855
|
+
"""(x, y, width, height) of the work area of the monitor holding the
|
|
856
|
+
studio, or None."""
|
|
857
|
+
if not _STATE["available"]:
|
|
858
|
+
return None
|
|
859
|
+
return _STATE["workarea"]
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def updates():
|
|
863
|
+
"""Monotone count of frame-rect updates seen — cheap change detection."""
|
|
864
|
+
return _STATE["updates"]
|