meltygui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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"]