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,1560 @@
1
+ """Custom application titlebar (Toggles.Melty.enhanced_titlebar).
2
+
3
+ Removes the server-side decoration so the UI extends to the top of the
4
+ display, draws the window controls as overlay-drawlist widgets in the top
5
+ corners — WHICH of minimize / maximize / close, and on which side, follows
6
+ the desktop's own title-bar button setting (titlebar_buttons.py, or
7
+ Toggles.Melty.titlebar_button_layout; an OS window whose root meltygui window
8
+ has a header hosts them inside that header, draw_header_controls) — and
9
+ hands drag / edge-resize back to the window manager via
10
+ _NET_WM_MOVERESIZE — so snapping, tiling and drag smoothness stay native.
11
+
12
+ Backend support. X11 (and XWayland): everything above. Native Wayland:
13
+ GLFW has no public route to xdg_toplevel.move()/resize(), so the OS window
14
+ KEEPS a frame there — GLFW's own fallback frame (a caption strip + borders,
15
+ compositor-driven move/resize) once Toggles.Melty.wayland_native_frame has
16
+ switched libdecor off at the first glfw.init (glfw_utils
17
+ .apply_wayland_frame_hint) — or, with Toggles.Melty.wayland_show_frame off
18
+ (the default), no frame at all. This module then contributes the buttons
19
+ (always on Wayland; Toggles.Melty.enhanced_titlebar is an X11 knob), the
20
+ right-drag resize (a cursor-driven drag of the OS window's own frame edges
21
+ through the edge physics, gl_gui/os_frame.py — bottom-right corner, or the
22
+ top-left on a DOUBLE right-drag, the meltygui windows' rule) and, through
23
+ gl_gui/wayland_move.py, the SAME strip / drag-anywhere move and edge resize
24
+ as X11: xdg_toplevel.move / .resize sent straight to the compositor (what
25
+ Super+drag and the caption strip do), the grab then driven by GNOME. With libdecor still on, its own
26
+ title bar carries the buttons and nothing here draws (backend_supported
27
+ is False).
28
+
29
+ Everything here runs on the visualization thread inside the imgui frame
30
+ (called from LSDStudio.render). The decoration attribute is synced live
31
+ each frame, so flipping the toggle takes effect without a restart.
32
+ """
33
+
34
+ import ctypes
35
+ import sys
36
+ import time
37
+
38
+ import meltygui.core.windowing.window_api as glfw
39
+ import meltygui_imgui as imgui
40
+ import OpenGL.GL as gl
41
+
42
+ import meltygui.core.input.mouse_cursor as mouse_cursor
43
+ import meltygui.core.windowing.titlebar_buttons as titlebar_buttons
44
+ import meltygui.core.windowing.wayland_move as wayland_move
45
+ import meltygui.core.input.hypr_left_drag as hypr_left_drag
46
+ from meltygui.core.graphics.gl_state import GLState
47
+ from meltygui.core.graphics.gl_state import is_gl_thread
48
+ from meltygui.core.graphics.shader_func import shader_func
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # X11 backend: _NET_WM_MOVERESIZE via ctypes → libX11
52
+ # ---------------------------------------------------------------------------
53
+
54
+ # direction values from the EWMH spec
55
+ _SIZE_TOPLEFT = 0
56
+ _SIZE_TOP = 1
57
+ _SIZE_TOPRIGHT = 2
58
+ _SIZE_RIGHT = 3
59
+ _SIZE_BOTTOMRIGHT = 4
60
+ _SIZE_BOTTOM = 5
61
+ _SIZE_BOTTOMLEFT = 6
62
+ _SIZE_LEFT = 7
63
+ _MOVE = 8
64
+
65
+ _CLIENT_MESSAGE = 33
66
+ _BUTTON_RELEASE = 5
67
+ _BUTTON_RELEASE_MASK = 1 << 3
68
+ _SUBSTRUCTURE_MASK = (1 << 19) | (1 << 20) # SubstructureNotify | SubstructureRedirect
69
+
70
+
71
+ class _XClientMessageEvent(ctypes.Structure):
72
+ _fields_ = [
73
+ ("type", ctypes.c_int),
74
+ ("serial", ctypes.c_ulong),
75
+ ("send_event", ctypes.c_int),
76
+ ("display", ctypes.c_void_p),
77
+ ("window", ctypes.c_ulong),
78
+ ("message_type", ctypes.c_ulong),
79
+ ("format", ctypes.c_int),
80
+ ("data", ctypes.c_long * 5),
81
+ ("_pad", ctypes.c_char * 96), # pad out to XEvent union size
82
+ ]
83
+
84
+
85
+ class _XButtonEvent(ctypes.Structure):
86
+ _fields_ = [
87
+ ("type", ctypes.c_int),
88
+ ("serial", ctypes.c_ulong),
89
+ ("send_event", ctypes.c_int),
90
+ ("display", ctypes.c_void_p),
91
+ ("window", ctypes.c_ulong),
92
+ ("root", ctypes.c_ulong),
93
+ ("subwindow", ctypes.c_ulong),
94
+ ("time", ctypes.c_ulong),
95
+ ("x", ctypes.c_int),
96
+ ("y", ctypes.c_int),
97
+ ("x_root", ctypes.c_int),
98
+ ("y_root", ctypes.c_int),
99
+ ("state", ctypes.c_uint),
100
+ ("button", ctypes.c_uint),
101
+ ("same_screen", ctypes.c_int),
102
+ ("_pad", ctypes.c_char * 96),
103
+ ]
104
+
105
+
106
+ _x11 = None
107
+ _moveresize_atom = None
108
+
109
+
110
+ def _lib():
111
+ global _x11
112
+ if _x11 is None:
113
+ _x11 = ctypes.CDLL("libX11.so.6")
114
+ _x11.XInternAtom.restype = ctypes.c_ulong
115
+ _x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
116
+ _x11.XUngrabPointer.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
117
+ _x11.XSendEvent.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int,
118
+ ctypes.c_long, ctypes.c_void_p]
119
+ _x11.XFlush.argtypes = [ctypes.c_void_p]
120
+ _x11.XDefaultRootWindow.restype = ctypes.c_ulong
121
+ _x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
122
+ _x11.XQueryPointer.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + \
123
+ [ctypes.c_void_p] * 7
124
+ return _x11
125
+
126
+
127
+ def _handles(window):
128
+ return glfw.get_x11_display(), glfw.get_x11_window(window)
129
+
130
+
131
+ def _root_pointer(x11, dpy, root):
132
+ r = ctypes.c_ulong()
133
+ child = ctypes.c_ulong()
134
+ rx, ry, wx, wy = (ctypes.c_int() for _ in range(4))
135
+ mask = ctypes.c_uint()
136
+ x11.XQueryPointer(dpy, root, ctypes.byref(r), ctypes.byref(child),
137
+ ctypes.byref(rx), ctypes.byref(ry),
138
+ ctypes.byref(wx), ctypes.byref(wy), ctypes.byref(mask))
139
+ return rx.value, ry.value
140
+
141
+
142
+ def _synthesize_button_release(x11, dpy, win, root, button=1):
143
+ # The WM's grab swallows the real ButtonRelease, so GLFW (and imgui's
144
+ # process_inputs) would think the button stayed down until the next real
145
+ # click. A release sent only to our own window fixes the client-side
146
+ # state without touching the server's physical button state the WM is
147
+ # tracking for the drag.
148
+ ev = _XButtonEvent()
149
+ ev.type = _BUTTON_RELEASE
150
+ ev.display = dpy
151
+ ev.window = win
152
+ ev.root = root
153
+ ev.time = 0 # CurrentTime
154
+ ev.x_root, ev.y_root = _root_pointer(x11, dpy, root)
155
+ ev.state = 1 << (7 + button) # Button<N>Mask (Button1Mask = 1<<8)
156
+ ev.button = button
157
+ ev.same_screen = 1
158
+ x11.XSendEvent(dpy, win, True, _BUTTON_RELEASE_MASK, ctypes.byref(ev))
159
+
160
+
161
+ def _begin_moveresize(window, direction, button=1):
162
+ """Hand the in-progress button press to the WM as a move/resize."""
163
+ global _moveresize_atom
164
+ try:
165
+ x11 = _lib()
166
+ dpy, win = _handles(window)
167
+ if not dpy or not win:
168
+ return False
169
+ root = x11.XDefaultRootWindow(dpy)
170
+ if _moveresize_atom is None:
171
+ _moveresize_atom = x11.XInternAtom(dpy, b"_NET_WM_MOVERESIZE", 0)
172
+ rx, ry = _root_pointer(x11, dpy, root)
173
+
174
+ x11.XUngrabPointer(dpy, 0) # release the implicit press grab so the WM can take it
175
+ ev = _XClientMessageEvent()
176
+ ev.type = _CLIENT_MESSAGE
177
+ ev.display = dpy
178
+ ev.window = win
179
+ ev.message_type = _moveresize_atom
180
+ ev.format = 32
181
+ ev.data[0] = rx
182
+ ev.data[1] = ry
183
+ ev.data[2] = direction
184
+ ev.data[3] = button
185
+ ev.data[4] = 1 # source = normal application
186
+ x11.XSendEvent(dpy, root, False, _SUBSTRUCTURE_MASK, ctypes.byref(ev))
187
+ _synthesize_button_release(x11, dpy, win, root, button)
188
+ x11.XFlush(dpy)
189
+ return True
190
+ except Exception:
191
+ return False
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Widget: window controls + drag strip + edge resize
196
+ # ---------------------------------------------------------------------------
197
+
198
+ _BTN_H = 30.0 # top_inset: chrome height to keep clear (buttons ≈ this)
199
+ _pressed_button = None # index armed by a press, fires on release inside
200
+
201
+ # The drag strip participates in the normal input-handler pipeline as the
202
+ # WORST-priority drag subscriber: on mouse-down the handler gives the drag
203
+ # to the best-priority hovered subscriber, so any view that actually uses
204
+ # click-and-drag (window headers, sliders, dnd, ...) captures it and the
205
+ # strip only receives the drag when nothing else under the cursor wants one.
206
+ _STRIP_ID = "enhanced_titlebar_strip"
207
+ _RESIZE_ID = "enhanced_titlebar_rdrag_resize"
208
+ _STRIP_PRIORITY = 10 ** 9
209
+ _wm_move_started = False # latch: dragged is per-frame, the WM move starts once
210
+
211
+ # Right-drag resize gesture: {"top_left": bool, "x": last total_dx, "y":
212
+ # last total_dy}, latched on the first dragged frame and dropped on release.
213
+ _rdrag = None
214
+
215
+
216
+ def top_inset():
217
+ """px of top-edge chrome the app should keep clear (the window-control
218
+ row) — 0 when the custom titlebar is off. For overlays that would
219
+ otherwise collide with the buttons (e.g. the GPU readout)."""
220
+ return _BTN_H if titlebar_enabled() else 0.0
221
+
222
+
223
+ def _on_wayland():
224
+ try:
225
+ return glfw.get_platform() == glfw.PLATFORM_WAYLAND
226
+ except AttributeError:
227
+ return False # pre-3.4 glfw on linux = X11
228
+
229
+
230
+ def backend_supported():
231
+ """X11 always; native Wayland only once libdecor is out of the picture
232
+ (the fallback frame has no buttons — ours fill in), never beside
233
+ libdecor's own title bar. Never off Linux: the move / resize / frame
234
+ plumbing behind it is Xlib and Wayland."""
235
+ if _on_wayland():
236
+ from meltygui.core.windowing.glfw_utils import wayland_native_frame_active
237
+ return wayland_native_frame_active()
238
+ return sys.platform.startswith("linux")
239
+
240
+
241
+ def titlebar_enabled():
242
+ """X11: Toggles.Melty.enhanced_titlebar (it replaces the WM frame, an
243
+ opt-in). Wayland: whenever the native frame is up — GLFW's fallback frame
244
+ carries no buttons, so ours are the only min/max/close the window gets,
245
+ and the toggle is not consulted."""
246
+ if _on_wayland():
247
+ return backend_supported()
248
+ from meltygui.core.runtime.toggles import Toggles
249
+ return Toggles.Melty.enhanced_titlebar and backend_supported()
250
+
251
+
252
+ def wants_os_decoration():
253
+ """DECORATED for the OS window. Wayland: with libdecor still up, always
254
+ (its frame is the window's chrome); on the native frame,
255
+ Toggles.Melty.wayland_show_frame — off = frameless, resize by right-drag,
256
+ move by the compositor. X11: only while the enhanced titlebar is off (it
257
+ replaces the WM's frame with _NET_WM_MOVERESIZE gestures). Boot hint and
258
+ per-frame sync both read this."""
259
+ if _on_wayland():
260
+ if not backend_supported():
261
+ return True
262
+ from meltygui.core.runtime.toggles import Toggles
263
+ return bool(Toggles.Melty.wayland_show_frame)
264
+ return not titlebar_enabled()
265
+
266
+
267
+ def sync_decoration(window):
268
+ """Apply the toggle live: called every frame on the viz thread."""
269
+ want_bar = titlebar_enabled()
270
+ decorated = bool(glfw.get_window_attrib(window, glfw.DECORATED))
271
+ want_decorated = wants_os_decoration()
272
+ if decorated != want_decorated:
273
+ glfw.set_window_attrib(window, glfw.DECORATED,
274
+ glfw.TRUE if want_decorated else glfw.FALSE)
275
+ return want_bar
276
+
277
+
278
+ def _edge_at(mx, my, w, h, border, corner):
279
+ """EWMH resize direction for a pointer at (mx, my), or None. An
280
+ off-window pointer — the backend's (-1, -1) / -FLT_MAX sentinel — is
281
+ never on an edge (it used to read as the top-left corner)."""
282
+ if mx < 0 or my < 0:
283
+ return None
284
+ on_l, on_r = mx <= border, mx >= w - border
285
+ on_t, on_b = my <= border, my >= h - border
286
+ near_l, near_r = mx <= corner, mx >= w - corner
287
+ near_t, near_b = my <= corner, my >= h - corner
288
+ if (on_t and near_l) or (on_l and near_t):
289
+ return _SIZE_TOPLEFT
290
+ if (on_t and near_r) or (on_r and near_t):
291
+ return _SIZE_TOPRIGHT
292
+ if (on_b and near_l) or (on_l and near_b):
293
+ return _SIZE_BOTTOMLEFT
294
+ if (on_b and near_r) or (on_r and near_b):
295
+ return _SIZE_BOTTOMRIGHT
296
+ if on_t:
297
+ return _SIZE_TOP
298
+ if on_b:
299
+ return _SIZE_BOTTOM
300
+ if on_l:
301
+ return _SIZE_LEFT
302
+ if on_r:
303
+ return _SIZE_RIGHT
304
+ return None
305
+
306
+
307
+ # One directional shape per edge/corner (right edge |>, left edge <|, each
308
+ # corner its own), not the shared directional arrows. Pushed via
309
+ # mouse_cursor.request - these shapes are past imgui's control.
310
+ _EDGE_CURSOR = {
311
+ _SIZE_TOP: mouse_cursor.RESIZE_N, _SIZE_BOTTOM: mouse_cursor.RESIZE_S,
312
+ _SIZE_LEFT: mouse_cursor.RESIZE_W, _SIZE_RIGHT: mouse_cursor.RESIZE_E,
313
+ _SIZE_TOPLEFT: mouse_cursor.RESIZE_NW, _SIZE_BOTTOMRIGHT: mouse_cursor.RESIZE_SE,
314
+ _SIZE_TOPRIGHT: mouse_cursor.RESIZE_NE, _SIZE_BOTTOMLEFT: mouse_cursor.RESIZE_SW,
315
+ }
316
+
317
+
318
+ # EWMH direction → xdg_toplevel.resize_edge, for the Wayland edge zones.
319
+ _XDG_EDGE = {
320
+ _SIZE_TOP: wayland_move.EDGE_TOP, _SIZE_BOTTOM: wayland_move.EDGE_BOTTOM,
321
+ _SIZE_LEFT: wayland_move.EDGE_LEFT, _SIZE_RIGHT: wayland_move.EDGE_RIGHT,
322
+ _SIZE_TOPLEFT: wayland_move.EDGE_TOP_LEFT, _SIZE_TOPRIGHT: wayland_move.EDGE_TOP_RIGHT,
323
+ _SIZE_BOTTOMLEFT: wayland_move.EDGE_BOTTOM_LEFT, _SIZE_BOTTOMRIGHT: wayland_move.EDGE_BOTTOM_RIGHT,
324
+ }
325
+
326
+
327
+ def _wm_gestures_available():
328
+ """Can the strip / edge zones hand a gesture to the window manager?
329
+ X11: always (_NET_WM_MOVERESIZE). Wayland: once wayland_move found the
330
+ window's xdg_toplevel (LSDStudio attaches it after the input backend)."""
331
+ return wayland_move.available() if _on_wayland() else True
332
+
333
+
334
+ def _release_after_wayland_grab(window):
335
+ """The compositor's grab swallows the button release. X11 gets a real
336
+ synthesized X event; on Wayland GLFW's own state can't be poked, so the
337
+ input handler is fed the release here and the polls read the button as
338
+ up through wayland_move.button_masked until GLFW's next real event."""
339
+ from meltygui.core.melty import Melty
340
+ backend = getattr(Melty, "backend", None)
341
+ if backend is None or not hasattr(backend, "_on_button"):
342
+ return
343
+ for button in wayland_move.masked_buttons():
344
+ backend._on_button(window, button, glfw.RELEASE, 0)
345
+
346
+
347
+ def _begin_wm_move(window):
348
+ if _on_wayland():
349
+ if wayland_move.begin_move(window):
350
+ _release_after_wayland_grab(window)
351
+ return True
352
+ return False
353
+ return _begin_moveresize(window, _MOVE)
354
+
355
+
356
+ def _begin_wm_resize(window, direction):
357
+ if _on_wayland():
358
+ if wayland_move.begin_resize(window, _XDG_EDGE[direction]):
359
+ _release_after_wayland_grab(window)
360
+ return True
361
+ return False
362
+ return _begin_moveresize(window, direction)
363
+
364
+
365
+ def _toggle_maximize(window):
366
+ if _maximized(window):
367
+ glfw.restore_window(window)
368
+ else:
369
+ glfw.maximize_window(window)
370
+
371
+
372
+ def _workarea_for(window):
373
+ """(left, top, right, bottom) of the workarea of the monitor holding the
374
+ window's center — the resize bounds. Falls back to the primary monitor
375
+ when the center sits off every monitor (mid-drag between screens)."""
376
+ if _on_wayland():
377
+ # No window positions on Wayland: the bounds are the primary
378
+ # monitor's workarea SIZE anchored at the window's own top-left
379
+ # (which the compositor pins) - a cap on how far a resize may grow.
380
+ # The SURFACE may overhang the workarea by the shadow margin on
381
+ # every side (the window client is the content), so the cap is
382
+ # the workarea plus twice the margin.
383
+ _ax, _ay, aw, ah = glfw.get_monitor_workarea(glfw.get_primary_monitor())
384
+ ox, oy = content_origin()
385
+ inset = window_inset()
386
+ return 0.0, 0.0, float(aw + ox + inset), float(ah + oy + inset)
387
+ wx, wy = glfw.get_window_pos(window)
388
+ ww, wh = glfw.get_window_size(window)
389
+ cx, cy = wx + ww / 2, wy + wh / 2
390
+ target = None
391
+ for m in glfw.get_monitors():
392
+ mx, my = glfw.get_monitor_pos(m)
393
+ mode = glfw.get_video_mode(m)
394
+ if (mx <= cx < mx + mode.size.width
395
+ and my <= cy < my + mode.size.height):
396
+ target = m
397
+ break
398
+ if target is None:
399
+ target = glfw.get_primary_monitor()
400
+ ax, ay, aw, ah = glfw.get_monitor_workarea(target)
401
+ return float(ax), float(ay), float(ax + aw), float(ay + ah)
402
+
403
+
404
+ # FontAwesome glyphs (merged in the default UI font - see fonts._fa_merge),
405
+ # same icon set the rest of the app draws with.
406
+ _ICON_MINIMIZE = "\uf2d1" # fa window-minimize
407
+ _ICON_MAXIMIZE = "\uf2d0" # fa window-maximize
408
+ _ICON_RESTORE = "\uf2d2" # fa window-restore
409
+ _ICON_CLOSE = "\uf00d" # fa times
410
+ _ICON_MOVE = "\uf0b2" # fa arrows-alt - the desktop's left-drag move toggle (hypr_left_drag)
411
+ _ICONS = (_ICON_MINIMIZE, _ICON_MAXIMIZE, _ICON_RESTORE, _ICON_CLOSE, _ICON_MOVE)
412
+
413
+
414
+ def _button_icon(kind, maximized):
415
+ if kind == "minimize":
416
+ return _ICON_MINIMIZE
417
+ if kind == "maximize":
418
+ return _ICON_RESTORE if maximized else _ICON_MAXIMIZE
419
+ if kind == "move":
420
+ return _ICON_MOVE
421
+ return _ICON_CLOSE
422
+
423
+
424
+ def button_layout():
425
+ """(left kinds, right kinds) the chrome shows — each a tuple over
426
+ "minimize" / "maximize" / "close", in on-screen order: Toggles.Melty
427
+ .titlebar_button_layout (GNOME syntax) when set, else the desktop's own
428
+ title-bar button setting (titlebar_buttons.system_layout: gsettings,
429
+ kwinrc, xfwm4, the settings portal)."""
430
+ from meltygui.core.runtime.toggles import Toggles
431
+ pinned = Toggles.Melty.titlebar_button_layout
432
+ if pinned:
433
+ return titlebar_buttons.parse_gnome(pinned)
434
+ return titlebar_buttons.system_layout()
435
+
436
+
437
+ def drag_anywhere_enabled():
438
+ """Whether an unclaimed left drag on bare background moves the OS window
439
+ through meltygui's OWN xdg_toplevel.move this frame:
440
+ Toggles.Melty.move_drag_anywhere, and — on the patched Hyprland that
441
+ carries the window-gesture toggle (hypr_left_drag) — only while the
442
+ compositor's own left-drag move does NOT apply to this app's class
443
+ (the chrome's "move" button faded). Lit, the compositor drives the
444
+ move itself and meltygui stands down; faded (the studio always, an app
445
+ after a click), meltygui moves and resizes the window itself. The drag
446
+ strip is the app's caption and keeps moving either way."""
447
+ from meltygui.core.runtime.toggles import Toggles
448
+ if not Toggles.Melty.move_drag_anywhere:
449
+ return False
450
+ if hypr_left_drag.available():
451
+ return not hypr_left_drag.enabled()
452
+ return True
453
+
454
+
455
+ def control_kinds():
456
+ """button_layout() plus the desktop's own extra: on Lukas's patched
457
+ Hyprland (hypr_left_drag.available — the compositor has
458
+ general:left_drag_move) a "move" toggle, the hyprbars
459
+ `state = "left_drag_move"` button mirrored, unless
460
+ Toggles.Melty.titlebar_move_toggle hides it. It sits INNERMOST of the
461
+ right group like the hyprbars one (before minimize / maximize / close),
462
+ or ends the left group when everything is on the left."""
463
+ from meltygui.core.runtime.toggles import Toggles
464
+ left_kinds, right_kinds = button_layout()
465
+ if not (Toggles.Melty.titlebar_move_toggle and hypr_left_drag.available()):
466
+ return left_kinds, right_kinds
467
+ if right_kinds or not left_kinds:
468
+ return tuple(left_kinds), ("move",) + tuple(right_kinds)
469
+ return tuple(left_kinds) + ("move",), tuple(right_kinds)
470
+
471
+
472
+ def _button_metrics():
473
+ """(margin, gap, height, {kind: width}) shared by both groups: the
474
+ header's close button sizing (flat_button: glyph + px(15) wide, + px(8)
475
+ tall — every kind its own glyph, like the header) inset by
476
+ button_margin from the corner, button_gap apart."""
477
+ from meltygui.core.melty import Melty
478
+ # [tint=(1.0, 0.55, 0.2)]
479
+ button_margin = Melty.px(4.0)
480
+ # [tint=(1.0, 0.55, 0.2)]
481
+ button_gap = Melty.px(3.0)
482
+ sizes = {icon: imgui.calc_text_size(icon) for icon in _ICONS}
483
+ height = max(s.y for s in sizes.values()) + Melty.px(8.0)
484
+ return button_margin, button_gap, height, {icon: s.x + Melty.px(15.0) for icon, s in sizes.items()}
485
+
486
+
487
+ def _button_layout(disp_w, maximized):
488
+ """[(kind, icon, rect)] of every control the layout asks for, in
489
+ on-screen order: the left group from the top-left corner, the right
490
+ group right-aligned at the top-right, each inset button_margin from
491
+ its corner and button_gap apart."""
492
+ left_kinds, right_kinds = control_kinds()
493
+ button_margin, button_gap, height, widths = _button_metrics()
494
+ buttons = []
495
+ x0 = button_margin
496
+ for kind in left_kinds:
497
+ icon = _button_icon(kind, maximized)
498
+ width = widths[icon]
499
+ buttons.append((kind, icon, (x0, button_margin, x0 + width, button_margin + height)))
500
+ x0 += width + button_gap
501
+ right = []
502
+ x1 = disp_w - button_margin
503
+ for kind in reversed(right_kinds):
504
+ icon = _button_icon(kind, maximized)
505
+ width = widths[icon]
506
+ right.append((kind, icon, (x1 - width, button_margin, x1, button_margin + height)))
507
+ x1 -= width + button_gap
508
+ right.reverse()
509
+ return buttons + right
510
+
511
+
512
+ def _button_bands(buttons, disp_w):
513
+ """(left_edge, right_edge): the x range between the two control groups
514
+ — the drag strip's span (a corner group's whole column is the
515
+ buttons', not the strip's). The window's edges when a side is empty."""
516
+ left_edge = max((r[2] for kind, icon, r in buttons if r[0] < disp_w / 2), default=0.0)
517
+ right_edge = min((r[0] for kind, icon, r in buttons if r[0] >= disp_w / 2), default=disp_w)
518
+ return left_edge, right_edge
519
+
520
+
521
+ def chrome_insets():
522
+ """(left_px, right_px) the window controls take from the window's top
523
+ corners this frame — what a meltygui header drawn in the chrome row keeps
524
+ clear on each side (surface.root_view_kwargs: `header_indent` on the
525
+ left, a reserving `with_header_end` on the right). 0 for an empty side
526
+ or with the chrome off."""
527
+ if not titlebar_enabled():
528
+ return 0.0, 0.0
529
+ left_kinds, right_kinds = control_kinds()
530
+ button_margin, button_gap, height, widths = _button_metrics()
531
+ maximized = _maximized(_studio_window())
532
+
533
+ def group(kinds):
534
+ if not kinds:
535
+ return 0.0
536
+ return button_margin + sum(widths[_button_icon(k, maximized)] for k in kinds) \
537
+ + button_gap * len(kinds)
538
+
539
+ return group(left_kinds), group(right_kinds)
540
+
541
+
542
+ # Melty.frame_count of the frame whose root view hosted the controls
543
+ # (draw_header_controls): paint_window_controls and draw_titlebar's button
544
+ # hits stand down for it - the header painted and clicked them.
545
+ _hosted_frame = -1
546
+
547
+
548
+ def draw_header_controls(draw_state=None, **kwargs):
549
+ """The `with_header_end` of an OS window's root view (surface
550
+ .root_view_kwargs): the window controls painted INSIDE the root's meltygui
551
+ header, where a header's close button sits — the same flat_button paint
552
+ as _paint_buttons, in the root's own tile (an overlay paint under a
553
+ cached root tile was blitted over — the glyphs vanished, 09-12), hover
554
+ through the wrapper's repaint of a bounding-hovered view, clicks through
555
+ draw_state.on_action. In the flow it claims the RIGHT group's width, so
556
+ the wrapper right-aligns it under those buttons and clips the header
557
+ before them (draw_state.header_end_width); both groups are painted at
558
+ their corner rects (the root fills the surface, so window and surface
559
+ coordinates agree). The overlay path stands down for the frame
560
+ (_hosted_frame)."""
561
+ global _hosted_frame
562
+ from meltygui.core.melty import Melty
563
+ imgui.dummy(chrome_insets()[1], 0)
564
+ if draw_state is None or not titlebar_enabled():
565
+ return
566
+ window = _studio_window()
567
+ io = imgui.get_io()
568
+ buttons = _button_layout(io.display_size.x, _maximized(window))
569
+ mx, my = io.mouse_pos.x, io.mouse_pos.y
570
+ over_button = next((i for i, (kind, icon, (x0, y0, x1, y1)) in enumerate(buttons)
571
+ if x0 <= mx <= x1 and y0 <= my <= y1), None)
572
+ if not draw_state._bounding_hovered or Melty.on_drag:
573
+ over_button = None
574
+ _hosted_frame = Melty.frame_count
575
+ _paint_buttons(imgui.get_window_draw_list(), buttons, over_button)
576
+ for i, (kind, icon, (x0, y0, x1, y1)) in enumerate(buttons):
577
+ # Same delivery as flat_button's own click (priority_delta=4 over
578
+ # the body's registrations, the plain pointer over the button).
579
+ if draw_state.on_action("left_mouse_clicked", view_id=f"titlebar_button_{i}",
580
+ rect=(x0, y0, x1, y1), priority_delta=4,
581
+ cursor=mouse_cursor.ARROW) is not None:
582
+ _activate_button(kind, window)
583
+
584
+
585
+ def _activate_button(kind, window):
586
+ """What a control does: minimize / maximize / close (the close deferred
587
+ to the merge window while pending state would be lost) / the desktop's
588
+ left-drag-move toggle for this app (hypr_left_drag.toggle)."""
589
+ if kind == "minimize":
590
+ glfw.iconify_window(window)
591
+ elif kind == "maximize":
592
+ _toggle_maximize(window)
593
+ elif kind == "move":
594
+ hypr_left_drag.toggle()
595
+ elif _close_blocked_by_merge():
596
+ pass # merge window opened instead; app stays up
597
+ else:
598
+ _close_window(window)
599
+
600
+
601
+ def _studio_window():
602
+ """The active OS window, accepting both native and GLFW handles.
603
+
604
+ Keep mocked vis handles out: ctypes can coerce MagicMock to a garbage
605
+ pointer. Native windows are Python objects with a class-level marker.
606
+ """
607
+ import ctypes
608
+ from meltygui.core.melty import Melty
609
+ window = Melty.glfw_window or getattr(Melty.vis, "window", None)
610
+ return window if glfw.is_native_window(window) or isinstance(window, ctypes._Pointer) else None
611
+
612
+
613
+ def _main_window_ds():
614
+ """The Main Window's draw_state (draw_main) — the owner the controls'
615
+ flat-mask marks ride under. In an app (surface.py, no draw_main) the
616
+ surface's root meltygui window: with its header in the chrome row its
617
+ cached tile covers the corners, and an OWNERLESS mark never keeps a
618
+ blit from copying over the buttons. None before the root's first
619
+ frame."""
620
+ from meltygui.core.melty import Melty
621
+ registry = getattr(Melty, "draw_state_registry", None) or {}
622
+ main = next((d for d in registry.values() if getattr(d, "name", None) == "Main Window"), None)
623
+ if main is not None:
624
+ return main
625
+ roots = getattr(Melty, "root_draw_states", None) or {}
626
+ return next((d for entries in roots.values() for d in entries), None)
627
+
628
+
629
+ def paint_window_controls(draw_list):
630
+ """Paint the min/max/close controls — from draw_melty_windows, on the
631
+ MAIN WINDOW draw list's top channel just before Melty.end_frame, i.e.
632
+ BEFORE the capture/shadow passes (draw_titlebar keeps the hit logic).
633
+ That placement is the whole point: the shadow composite paints each
634
+ depth mark's drop shadow and lit rim INTO the framebuffer and the
635
+ overlay list renders after it, so buttons drawn there covered their own
636
+ rim with their background (the "rect in front of it" look). Painted
637
+ here they get composited exactly like a header's close button.
638
+
639
+ Each button also marks the FLAT mask at the top paint rank (owner: the
640
+ Main Window's draw_state — an ownerless mask rect never terminates the
641
+ mask build's parent walk), which is what keeps a cached window blitted
642
+ under the corner from copying over them, and what gives them their
643
+ depth for the shadow pass — the same mark a window gets."""
644
+ global _pressed_button
645
+ from meltygui.core.melty import Melty
646
+ from meltygui.core.runtime.toggles import shadow_depth_at
647
+ if not titlebar_enabled() or _hosted_frame == Melty.frame_count:
648
+ return # off, or the root's header painted them this frame (draw_header_controls)
649
+ window = _studio_window()
650
+ io = imgui.get_io()
651
+ disp_w = io.display_size.x
652
+ mx, my = io.mouse_pos.x, io.mouse_pos.y
653
+ maximized = _maximized(window)
654
+ buttons = _button_layout(disp_w, maximized)
655
+ over_button = next((i for i, (kind, icon, (x0, y0, x1, y1)) in enumerate(buttons)
656
+ if x0 <= mx <= x1 and y0 <= my <= y1), None)
657
+ # Top-most of everything painted: the highest paint rank + a little depth.
658
+ layer = Melty.nested_layer_max - 1
659
+ rank = shadow_depth_at(2, layer)
660
+ owner = _main_window_ds()
661
+ if Melty.cache is not None and owner is not None:
662
+ for i, (kind, icon, (x0, y0, x1, y1)) in enumerate(buttons):
663
+ Melty.cache.mask_mark_rect(owner, layer, rank, x0, y0, x1 - x0, y1 - y0,
664
+ f"titlebar_button_{i}", corner_radius=Melty.px(6.0))
665
+ if Melty.channels_split:
666
+ draw_list.channels_set_current(Melty.max_depth - 1)
667
+ _paint_buttons(draw_list, buttons, over_button)
668
+
669
+
670
+ # Toggles.show_fps readout. Change the pill/text values to restyle it;
671
+ # FPS_GAP is its distance from the right control group (or the window edge).
672
+ FPS_GAP = 8.0
673
+ FPS_PAD_X = 6.0
674
+ FPS_PILL_COLOR = (0.0, 0.0, 0.0, 0.55)
675
+ FPS_TEXT_COLOR = (0.85, 0.95, 0.85)
676
+
677
+
678
+ # (label, chrome) a Surface measured for the frame being drawn; consumed by
679
+ # paint_fps. None: the studio's window, which falls back to imgui's rate.
680
+ _fps_note = None
681
+
682
+
683
+ def note_fps_label(label, chrome):
684
+ global _fps_note
685
+ _fps_note = (label, chrome)
686
+
687
+
688
+ def paint_fps(overlay):
689
+ """Toggles.show_fps: the OS window's frame-rate label, right-aligned in
690
+ the titlebar strip before the right control group (the top-right corner
691
+ of a window without Melty chrome). Called from Melty.end_frame with the
692
+ overlay list at its top channel: the label changes every frame, and the
693
+ overlay renders after the cached tiles' blits, so no view is
694
+ invalidated to keep it current."""
695
+ global _fps_note
696
+ from meltygui.core.melty import Melty
697
+ from meltygui.hdr_color import pack_color
698
+ io = imgui.get_io()
699
+ label, chrome = _fps_note or (f"{io.framerate:.0f} fps", True)
700
+ _fps_note = None
701
+ disp_w = io.display_size.x
702
+ right = disp_w
703
+ if chrome and titlebar_enabled():
704
+ buttons = _button_layout(disp_w, _maximized(_studio_window()))
705
+ right = _button_bands(buttons, disp_w)[1]
706
+ text = imgui.calc_text_size(label)
707
+ pad = Melty.px(FPS_PAD_X)
708
+ height = min(Melty.px(_BTN_H), text.y + Melty.px(6.0))
709
+ x1 = right - Melty.px(FPS_GAP)
710
+ x0 = x1 - text.x - 2 * pad
711
+ y0 = (Melty.px(_BTN_H) - height) * 0.5
712
+ overlay.add_rect_filled(x0, y0, x1, y0 + height, pack_color(*FPS_PILL_COLOR), rounding=Melty.px(6.0))
713
+ overlay.add_text(x0 + pad, y0 + (height - text.y) * 0.5, pack_color(*FPS_TEXT_COLOR, 1.0), label)
714
+
715
+
716
+ def _paint_buttons(dl, buttons, over_button):
717
+ """The controls (`buttons` = _button_layout's list), each painted EXACTLY like a window header's
718
+ close button (draw_header_end): the same flat_button call — colour
719
+ (9, 1, 1), glyph + px(15) by glyph + px(8), rounded, theme-mixed glyph,
720
+ hover brightening, a depth mark for the shadow pass (drop shadow + lit
721
+ rim) — run under Toggles.Melty.melty_window_tint set in the style
722
+ manager, the way a header runs under its window's tint (that tint is
723
+ what make_color_rgb mixes the colour against). The previous tint is
724
+ restored afterwards."""
725
+ from meltygui.view.header_view import flat_button
726
+ from meltygui.core.cache.tile_cache import add_shadow
727
+ from meltygui.core.melty import Melty
728
+ from meltygui.core.runtime.toggles import Toggles
729
+ # The header close button's colour (draw_header_end).
730
+ # [tint=(0.9, 0.15, 0.15)]
731
+ close_color = (9, 1, 1)
732
+ # The move toggle while left-drag move is OFF for this app: the
733
+ # hyprbars button's fade — the glyph this grey (text_color, the
734
+ # full-range bypass) on a disc this much darker (tint_value; 0.16 lit).
735
+ # [tint=(0.9, 0.15, 0.15)]
736
+ move_off_glyph = (0.4, 0.4, 0.4)
737
+ # [tint=(0.9, 0.15, 0.15)]
738
+ move_off_disc_value = 0.06
739
+ style_manager = Melty.style_manager
740
+ previous_tint = style_manager.get_tint() if style_manager is not None else None
741
+ chrome_tint = Toggles.Melty.melty_window_tint
742
+ if style_manager is not None and chrome_tint and len(chrome_tint) >= 3:
743
+ style_manager.set_imgui_tint(*chrome_tint[:4])
744
+ try:
745
+ for i, (kind, icon, (x0, y0, x1, y1)) in enumerate(buttons):
746
+ # The header close's own lift over its surface (+2 over the
747
+ # flat-mask mark paint_window_controls stamped for the button).
748
+ add_shadow((x0, y0, x1 - x0, y1 - y0), corner_radius=Melty.px(6.0), clip=False,
749
+ layer=Melty.nested_layer_max - 1, depth=2)
750
+ faded = kind == "move" and not hypr_left_drag.enabled()
751
+ flat_button(icon, None, view_id=f"titlebar_button_{i}",
752
+ width=x1 - x0, height=y1 - y0, pos=(x0, y0),
753
+ hovered=(over_button == i), layout=False, draw_list=dl,
754
+ color=close_color,
755
+ **({"text_color": move_off_glyph, "tint_value": move_off_disc_value}
756
+ if faded else {}))
757
+ finally:
758
+ if style_manager is not None and previous_tint is not None:
759
+ style_manager.set_imgui_tint(*previous_tint)
760
+
761
+
762
+ def _close_blocked_by_merge():
763
+ """True when quitting would lose pending state: some file has BOTH pending
764
+ edits and unmerged external drift (PendingSave.needs_merge). Instead of
765
+ closing, surface the merge window — the manual merge is the only way that
766
+ state resolves — with as much as possible already staged (its Auto-merge
767
+ run over every drifted file) and the conflicts that are left flashed;
768
+ Ctrl+M then applies. Errors never block the close."""
769
+ try:
770
+ from meltygui.editor.pending_save import PendingSave
771
+ if not PendingSave.needs_merge():
772
+ return False
773
+ from meltygui.core.runtime.extensions import get, call
774
+ from meltygui.core.diagnostics.notifications import notify
775
+ if get('conflicts_open') is None:
776
+ notify('External changes conflict with pending edits; save or discard them before closing.', tint=(1.0, 0.7, 0.2))
777
+ return True
778
+ call('conflicts_open', auto_merge=True)
779
+ notify("Unmerged external changes — merge before closing "
780
+ "(Ctrl+M applies the staged merge)", tint=(1.0, 0.7, 0.2))
781
+ from meltygui.core.windowing.glfw_utils import request_render
782
+ request_render()
783
+ return True
784
+ except Exception:
785
+ return False
786
+
787
+
788
+ def _close_window(window):
789
+ """The close button's action. Flags the loop to exit and wakes it, so
790
+ the should_close check runs right after THIS frame instead of on the
791
+ next input event (the loop parks in glfw.wait_events). The surface
792
+ itself is hidden by the loop's exit path (LSDStudio._run_visualization's
793
+ finally) BEFORE the seconds-long teardown — never here, mid-frame: the
794
+ frame that fires this still swaps, and swapping onto a surface GLFW has
795
+ just unmapped is what the compositor-close callback path never does."""
796
+ from meltygui.core.windowing.glfw_utils import request_render
797
+ glfw.set_window_should_close(window, True)
798
+ request_render() # posts the empty event that wakes wait_events
799
+
800
+
801
+ def draw_titlebar(window):
802
+ """Per-frame entry point — call inside the imgui frame on the viz thread.
803
+
804
+ Handles decoration sync, the top-right window controls' hit logic
805
+ (their paint is paint_window_controls, earlier in the frame), the top
806
+ drag strip (double-click = maximize unless
807
+ Toggles.Melty.disable_double_click_maximize; with Toggles.Melty.move_drag_anywhere
808
+ an unclaimed left-drag anywhere moves too), edge/corner resize and the
809
+ right-drag resize. The WM gestures go through _NET_WM_MOVERESIZE on X11
810
+ and wayland_move (xdg_toplevel.move/resize) on Wayland.
811
+ """
812
+ global _pressed_button, _wm_move_started, _rdrag
813
+ if not sync_decoration(window):
814
+ _pressed_button = None
815
+ _wm_move_started = False
816
+ _rdrag = None
817
+ return
818
+
819
+ from meltygui.core.melty import Melty
820
+ from meltygui.core.runtime.toggles import Toggles
821
+
822
+ io = imgui.get_io()
823
+ disp_w, disp_h = io.display_size.x, io.display_size.y
824
+ mx, my = io.mouse_pos.x, io.mouse_pos.y
825
+ maximized = _maximized(window)
826
+ sync_window_geometry(window)
827
+ sync_input_region(window)
828
+ # The strip and edge gestures hand the drag to the window manager
829
+ # (_begin_wm_move / _begin_wm_resize) - on Wayland only once wayland_move
830
+ # is ready. The right-drag resize is app-driven (set_window_size) and
831
+ # runs on both; on Wayland it reads the surface relative pointer and
832
+ # always grabs the bottom-right corner (the top-left is pinned).
833
+ wayland = _on_wayland()
834
+ gestures = _wm_gestures_available()
835
+
836
+ # --- the control buttons in the top corners, per the desktop's setup --
837
+ # (titlebar_buttons: re-read while frames render, if a changed desktop
838
+ # setting lands without a restart; the focus hook re-reads too)
839
+ titlebar_buttons.refresh_if_stale(Toggles.Melty.titlebar_button_refresh_s)
840
+ hypr_left_drag.refresh_if_stale(Toggles.Melty.titlebar_button_refresh_s)
841
+ buttons = _button_layout(disp_w, maximized)
842
+ strip_left, strip_right = _button_bands(buttons, disp_w)
843
+
844
+ over_button = None
845
+ for i, (kind, icon, (x0, y0, x1, y1)) in enumerate(buttons):
846
+ if x0 <= mx <= x1 and y0 <= my <= y1:
847
+ over_button = i
848
+ break
849
+
850
+ # --- edge/corner resize (skip while maximized) -------------------------
851
+ border = float(Toggles.Melty.resize_border)
852
+ corner = float(Toggles.Melty.resize_corner)
853
+ edge = None
854
+ if gestures and not maximized and over_button is None:
855
+ edge = _edge_at(mx, my, disp_w, disp_h, border, corner)
856
+ if edge is not None:
857
+ mouse_cursor.request(_EDGE_CURSOR[edge])
858
+ if imgui.is_mouse_clicked(0) and _begin_wm_resize(window, edge):
859
+ return
860
+
861
+ # --- buttons: arm on press, fire on release inside ---------------------
862
+ # (a root header hosting the controls clicks them itself -
863
+ # draw_header_controls - so only the strip / anywhere gating hits
864
+ # over_button on such a frame)
865
+ hosted = _hosted_frame == Melty.frame_count
866
+ if hosted:
867
+ _pressed_button = None
868
+ elif over_button is not None and imgui.is_mouse_clicked(0):
869
+ _pressed_button = over_button
870
+ if _pressed_button is not None and imgui.is_mouse_released(0):
871
+ if over_button == _pressed_button and _pressed_button < len(buttons):
872
+ _activate_button(buttons[_pressed_button][0], window)
873
+ _pressed_button = None
874
+
875
+ # --- drag strip along the top edge -------------------------------------
876
+ # Register as the lowest-priority drag subscriber while the cursor is in
877
+ # the strip; the handler's down-capture means a view that uses
878
+ # click-and-drag always wins, and the strip only sees the drag when
879
+ # nothing else claimed it. Clean clicks are untouched (dragged fires
880
+ # only past the handler's drag threshold). Double-click = maximize,
881
+ # same lowest-priority rule.
882
+ # drag_anywhere_enabled (Toggles.Melty.move_drag_anywhere, gated by the
883
+ # desktop's left-drag-move toggle where it exists) widens the drag (not
884
+ # the double-click) to the whole window at the same worst priority: even
885
+ # a drag nobody else claimed - bare background - moves the OS window.
886
+ # Toggles.Melty.disable_double_click_maximize drops the double-click
887
+ # subscription entirely, so the click reaches the view under the cursor.
888
+ # Both the strip and the right-drag resize below subscribe NON-BLOCKING:
889
+ # a closable meltygui window is an event blocker (core_render registers
890
+ # `blocker=closable`) and a meltygui app's root IS such a window, pinned
891
+ # over the whole surface — a plain subscription at worst priority sat
892
+ # behind that blocker and was dropped before dispatch (the app's left /
893
+ # right drags never reached the OS chrome; the studio's roots leave its
894
+ # background bare, which is why they worked there). A non_blocking
895
+ # subscription survives the blocker pass and still resolves LAST, so a
896
+ # view that claims the drag keeps winning (input_handler: the
897
+ # non-blocking chain stops at the first blocking subscriber).
898
+ strip_h = float(Toggles.Melty.drag_strip_height)
899
+ in_strip = my <= strip_h and strip_left <= mx < strip_right
900
+ strip_double_click = in_strip and not Toggles.Melty.disable_double_click_maximize
901
+ anywhere = drag_anywhere_enabled() and over_button is None
902
+ if gestures and edge is None and (in_strip or anywhere):
903
+ # Move pointer only where the strip would actually get the drag
904
+ # (cursor_gate): bare background, never over a view that claims it.
905
+ Melty.event_handler.register_hovered(
906
+ _STRIP_ID,
907
+ ["non_blocking_left_mouse_dragged", "non_blocking_left_mouse_double_clicked"]
908
+ if strip_double_click else ["non_blocking_left_mouse_dragged"],
909
+ priority=_STRIP_PRIORITY,
910
+ cursor=mouse_cursor.MOVE if Toggles.Melty.window_move_cursor else None,
911
+ cursor_gate="left_mouse_dragged")
912
+ strip_events = (getattr(Melty, "events", None) or {}).get(_STRIP_ID, {})
913
+ if _wm_move_started and not Melty.event_handler.is_down("left_mouse"):
914
+ _wm_move_started = False # synthetic release landed - re-arm
915
+ if "non_blocking_left_mouse_double_clicked" in strip_events \
916
+ and not Toggles.Melty.disable_double_click_maximize:
917
+ _toggle_maximize(window)
918
+ elif "non_blocking_left_mouse_dragged" in strip_events and not _wm_move_started:
919
+ _wm_move_started = True
920
+ if _begin_wm_move(window):
921
+ return
922
+
923
+ # --- right-drag resize, anywhere in the window -------------------------
924
+ # Same worst-priority pattern as the strip, but over the WHOLE window:
925
+ # any view that actually uses a right drag (view orbits, meltygui window
926
+ # resize) captures it first, and plain right-CLICKS are untouched
927
+ # (dragged only fires past the handler's drag threshold, so context
928
+ # menus keep working). The drag is a cursor-driven drag of the OS
929
+ # window's OWN frame edges through the edge physics (os_frame.queue_drag
930
+ # → the roots' frame passes): the bottom-right corner, or the top-left
931
+ # on a DOUBLE right-drag (press-press-drag - the meltygui windows' rule),
932
+ # decided at the press for the whole gesture. The screen's work area is
933
+ # the wall; an edge blocked there grows the window on the other side,
934
+ # just like a meltygui window's edge against the display.
935
+ if not maximized and over_button is None and edge is None:
936
+ Melty.event_handler.register_hovered(
937
+ _RESIZE_ID, ["non_blocking_right_mouse_dragged", "non_blocking_right_mouse_double_dragged"],
938
+ priority=_STRIP_PRIORITY)
939
+ # (the drag events themselves are consumed by poll_os_window_drag at
940
+ # the frame's START - before the root windows solve - so the OS edge
941
+ # moves in the same frame the hand did)
942
+ if _rdrag is not None:
943
+ mouse_cursor.request(_EDGE_CURSOR[_SIZE_TOPLEFT if _rdrag["top_left"] else _SIZE_BOTTOMRIGHT])
944
+
945
+ # The buttons themselves are painted earlier in the frame, in the main
946
+ # window draw list (paint_window_controls) - see there for why.
947
+
948
+
949
+ def poll_os_window_drag():
950
+ """Frame START (draw_melty_windows, right after os_frame.begin_frame):
951
+ turn this frame's right-drag events on the background (_RESIZE_ID,
952
+ registered by draw_titlebar) into cursor-driven drags of the OS
953
+ window's frame edges — os_frame.queue_drag with the drag total's
954
+ per-frame increment — so the first root window's pass solves them in
955
+ THIS frame. Polled at the end of the frame (draw_titlebar runs after
956
+ the meltygui windows and os_frame.flush) the drag landed a frame late."""
957
+ global _rdrag
958
+ from meltygui.core.melty import Melty
959
+ handler = getattr(Melty, "event_handler", None)
960
+ if handler is None:
961
+ return
962
+ resize_events = (getattr(Melty, "events", None) or {}).get(_RESIZE_ID, {})
963
+ released = not handler.is_down("right_mouse")
964
+ drag = (resize_events.get("non_blocking_right_mouse_double_dragged")
965
+ or resize_events.get("non_blocking_right_mouse_dragged"))
966
+ if drag is None:
967
+ if released:
968
+ _rdrag = None
969
+ return
970
+ import meltygui.core.windowing.os_frame as os_frame
971
+ if _rdrag is None:
972
+ _rdrag = {"top_left": "non_blocking_right_mouse_double_dragged" in resize_events, "x": 0.0, "y": 0.0}
973
+ index = 0 if _rdrag["top_left"] else 1
974
+ for axis, total in (("x", "total_dx"), ("y", "total_dy")):
975
+ now = float(getattr(drag, total, 0.0) or 0.0)
976
+ inc = now - _rdrag[axis]
977
+ _rdrag[axis] = now
978
+ os_frame.queue_drag(axis, index, inc)
979
+ # The next frame can carry the drag total. Consume its remainder
980
+ # against the old baseline before clearing it, not the whole drag again.
981
+ if released:
982
+ _rdrag = None
983
+
984
+
985
+ # ---------------------------------------------------------------------------
986
+ # Rounded window corners - a transparent framebuffer + a final alpha pass
987
+ # ---------------------------------------------------------------------------
988
+
989
+ def wants_transparent_framebuffer():
990
+ """Boot hint (GLFW TRANSPARENT_FRAMEBUFFER): only a frameless window
991
+ with a corner radius or a shadow margin needs per-pixel alpha at the
992
+ compositor."""
993
+ from meltygui.core.runtime.toggles import Toggles
994
+ return titlebar_enabled() and (Toggles.Melty.window_corner_radius > 0
995
+ or Toggles.Melty.window_shadow_margin > 0)
996
+
997
+
998
+ def _frame_transparent(window):
999
+ """Was the OS window CREATED with an alpha framebuffer (the boot hint)?
1000
+ Only then do the corner cut and the shadow margin make sense — on an
1001
+ opaque window the premultiply would paint the corners black."""
1002
+ try:
1003
+ return bool(window is not None
1004
+ and glfw.get_window_attrib(window, glfw.TRANSPARENT_FRAMEBUFFER))
1005
+ except Exception:
1006
+ return False
1007
+
1008
+
1009
+ def _maximized(window):
1010
+ """Is the OS window maximized? The ONE read every maximize-sensitive
1011
+ path takes (the shadow inset, the button glyph, the edge zones, the
1012
+ background right-drag's registration). GLFW's MAXIMIZED attribute
1013
+ everywhere but Hyprland: Hyprland sends the xdg `maximized` state to
1014
+ EVERY toplevel at map, to stop clients drawing their own decorations
1015
+ (XDGShell.cpp, "this forces apps to not draw CSD"), and never clears
1016
+ it — so GLFW read the studio as maximized for its whole life there:
1017
+ inset 0, no edge zones, and draw_titlebar never registered the
1018
+ background right-drag (09-09, "right-drag does nothing on
1019
+ Hyprland"). There the feed's own flag is the truth: Hyprland's
1020
+ `fullscreen == 1` (the maximize fullscreen mode) on the studio's
1021
+ client, False until the feed has seen the window."""
1022
+ if window is None:
1023
+ return False
1024
+ if _on_hyprland():
1025
+ # Whatever the compositor does with the window geometry, it still
1026
+ # forces the maximized state at map (XDGShell.cpp) - reading GLFW's
1027
+ # attribute on the state-honouring build zeroed the inset and
1028
+ # dropped the right-drag again (09-10, first login on that build).
1029
+ import meltygui.core.windowing.geometry_feed as geometry_feed
1030
+ frame = geometry_feed._STATE.get("frame") or {}
1031
+ return bool(frame.get("maximized", False))
1032
+ try:
1033
+ return bool(glfw.get_window_attrib(window, glfw.MAXIMIZED))
1034
+ except Exception:
1035
+ return False
1036
+
1037
+
1038
+ def shadow_reach(fb_w, fb_h):
1039
+ """px the shadow pass reaches past a caster's edge on a fb_w × fb_h
1040
+ framebuffer, from ShadowCast's own terms: it marches 16 depth slices of
1041
+ 1/max_steps above the receiver, each contributing
1042
+ hit_strength − height·hit_falloff (so the height budget is the smaller
1043
+ of the 16 slices and hit_strength/hit_falloff), offsets the sample by
1044
+ height·height_scale along the light direction — in UV, so the reach in
1045
+ px grows with the framebuffer — and blurs by height·blur_scale. The
1046
+ larger of the x/y reaches, rounded UP to 16 px so small resizes don't
1047
+ wobble the margin. Measured against the real pass (4072×2136: predicts
1048
+ 69/49 px right/down, the pass fades out by 57/42). 0 with the shadow
1049
+ pass off."""
1050
+ import math
1051
+ from meltygui.core.runtime.toggles import Toggles
1052
+ from meltygui.core.melty import Melty
1053
+ if not Toggles.filters or fb_w <= 0 or fb_h <= 0:
1054
+ return 0
1055
+ total_layers = 100.0 / ((Melty.max_layer - 1.0) * (Melty.max_depth - 1.0))
1056
+ depth_step = 1.0 / max(1.0, total_layers * 65535.0 / 2.0) # post_frame: max_steps = diff / 2
1057
+ height = 16.0 * depth_step
1058
+ hit_falloff = float(Toggles.shadow_hit_falloff)
1059
+ if hit_falloff > 0.0:
1060
+ height = min(height, float(Toggles.shadow_hit_strength) / hit_falloff)
1061
+ offset_uv = height * (float(Toggles.shadow_height_scale) + float(Toggles.shadow_blur_scale))
1062
+ lx, ly = Toggles.shadow_light_dir
1063
+ norm = math.hypot(lx, ly) or 1.0
1064
+ reach = max(offset_uv * abs(lx) / norm * fb_w, offset_uv * abs(ly) / norm * fb_h)
1065
+ return int(math.ceil(reach / 16.0)) * 16
1066
+
1067
+
1068
+ def _monitor_size(window):
1069
+ """Pixel size of the primary monitor's current video mode — the most a
1070
+ surface can span, so the reach derived from it is a CONSTANT."""
1071
+ try:
1072
+ mode = glfw.get_video_mode(glfw.get_primary_monitor())
1073
+ return int(mode.size.width), int(mode.size.height)
1074
+ except Exception:
1075
+ return None
1076
+
1077
+
1078
+ def window_inset():
1079
+ """px of transparent shadow margin around the content THIS frame: on
1080
+ the transparent frameless window, the larger of
1081
+ Toggles.Melty.window_shadow_margin and the shadow pass's reach
1082
+ (shadow_reach — a fixed margin truncated the shadow mid-fall, a hard
1083
+ band); 0 while maximized / fullscreen (the shadow collapses against
1084
+ the screen edges, as GTK's does) and 0 everywhere else. The reach is
1085
+ taken at the MONITOR's size, not the live surface: derived from the
1086
+ surface it changed mid-resize whenever a 16 px rounding boundary went
1087
+ by, and every change is a visible jump — the content shrinks by twice
1088
+ it, the geometry origin moves so the compositor shifts the surface,
1089
+ the right-drag's latched cap goes stale (a gap at the top) — and a
1090
+ resize hovering at the boundary flickered the margin on and off. The
1091
+ surface simply overhangs a little more than it needs on a small
1092
+ window. imgui's display is the content: SplitOverlayRenderer
1093
+ .process_inputs shrinks display_size by twice this and shifts the
1094
+ pointer; the masks and tiles keep the whole surface."""
1095
+ from meltygui.core.runtime.toggles import Toggles
1096
+ margin = int(Toggles.Melty.window_shadow_margin)
1097
+ if margin <= 0:
1098
+ return 0
1099
+ window = _studio_window()
1100
+ if not _frame_transparent(window) or _maximized(window) or _fullscreen(window):
1101
+ return 0
1102
+ size = _monitor_size(window)
1103
+ if size:
1104
+ margin = max(margin, shadow_reach(int(size[0]), int(size[1])))
1105
+ return margin
1106
+
1107
+
1108
+ def _fullscreen(window):
1109
+ """GLFW-fullscreen (a monitor set on the window). pyglfw hands back a
1110
+ ctypes POINTER even when it is NULL — never compare it to None, a null
1111
+ pointer is FALSY, not None (that mistake collapsed the margin to 0)."""
1112
+ try:
1113
+ return window is not None and bool(glfw.get_window_monitor(window))
1114
+ except Exception:
1115
+ return False
1116
+
1117
+
1118
+ def frame_geometry(fb_w, fb_h):
1119
+ """(origin, radius, content_size) of the frame for this frame's REAL
1120
+ framebuffer — origin the content's top-left in the surface — or
1121
+ ((0, 0), 0, (0, 0)) when the window is not transparent: the shadow
1122
+ composite's frame uniforms (no frame → everything is content)."""
1123
+ if not _frame_transparent(_studio_window()):
1124
+ return (0.0, 0.0), 0.0, (0.0, 0.0)
1125
+ inset = float(window_inset())
1126
+ ox, oy = content_origin()
1127
+ return (ox, oy), frame_corner_radius(), (float(fb_w) - ox - inset, float(fb_h) - oy - inset)
1128
+
1129
+
1130
+ def content_origin():
1131
+ """(x, y) of the content's top-left inside the surface: the shadow
1132
+ margin; (0, 0) while maximized / fullscreen."""
1133
+ window = _studio_window()
1134
+ if _maximized(window) or _fullscreen(window):
1135
+ return (0.0, 0.0)
1136
+ inset = float(window_inset())
1137
+ return (inset, inset)
1138
+
1139
+
1140
+ def frame_corner_radius():
1141
+ """Corner radius of the content this frame: the toggle, 0 while
1142
+ maximized (square against the screen edge)."""
1143
+ from meltygui.core.runtime.toggles import Toggles
1144
+ if _maximized(_studio_window()):
1145
+ return 0.0
1146
+ return float(Toggles.Melty.window_corner_radius)
1147
+
1148
+
1149
+ # Coverage of the CONTENT rounded rect - inset `inset` px into the
1150
+ # framebuffer, `content_size` wide, `radius` corners - at a pixel centre
1151
+ # (gl_FragCoord, origin bottom-left; the rect is symmetric so the flip is
1152
+ # free). One pixel of anti-aliasing ramp across the edge.
1153
+ _COVERAGE_GLSL = """
1154
+ float coverage(vec2 p) {
1155
+ vec2 half_size = content_size * 0.5;
1156
+ vec2 d = abs(p - origin - half_size) - (half_size - vec2(radius));
1157
+ float dist = length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - radius;
1158
+ return 1.0 - smoothstep(-0.5, 0.5, dist);
1159
+ }
1160
+ """
1161
+
1162
+ _CORNER_FRAG = """
1163
+ #version 330 core
1164
+ out vec4 FragColor;
1165
+ """ + _COVERAGE_GLSL + """
1166
+ void main() {
1167
+ FragColor = vec4(0.0, 0.0, 0.0, coverage(gl_FragCoord.xy));
1168
+ }
1169
+ """
1170
+
1171
+ @shader_func(fragment=_CORNER_FRAG)
1172
+ def _corner_alpha_pass(gl_state: GLState = None, content_size=(1.0, 1.0), origin=(0.0, 0.0),
1173
+ radius=0.0, **kwargs):
1174
+ gl.glBindVertexArray(gl_state.vao("fs_triangle"))
1175
+ gl.glDrawArrays(gl.GL_TRIANGLES, 0, 3)
1176
+
1177
+
1178
+ # The GLState for the passes, module-owned (hotswap keeps it).
1179
+ _corner_gl = globals().get("_corner_gl")
1180
+ # The input rect last handed to the compositor (sync_input_region).
1181
+ _input_rect_applied = globals().get("_input_rect_applied")
1182
+ # The window geometry last handed to the compositor (sync_window_geometry).
1183
+ _geometry_applied = globals().get("_geometry_applied")
1184
+ # True while OUR glfw.set_window_size is in flight - its framebuffer-size
1185
+ # callback is not a compositor configure (on_surface_resized).
1186
+ _self_resize = False
1187
+ # The surface size the framebuffer callback last reported (or the creation
1188
+ # size, note_surface_size): a callback repeating it is NOT a configure.
1189
+ _last_surface_size = globals().get("_last_surface_size")
1190
+
1191
+
1192
+ def note_surface_size(window):
1193
+ """Record the window's current framebuffer size as the last one seen —
1194
+ Surface.__init__, right after creation: GLFW re-fires the framebuffer
1195
+ callback with the UNCHANGED size when the surface enters an output or
1196
+ gets its fractional scale, and on_surface_resized read that as a
1197
+ compositor configure and regrew the surface by the margin once too
1198
+ often at every launch (09-12)."""
1199
+ global _last_surface_size
1200
+ try:
1201
+ _last_surface_size = tuple(int(v) for v in glfw.get_framebuffer_size(window))
1202
+ except Exception:
1203
+ _last_surface_size = None
1204
+
1205
+
1206
+ def _on_hyprland():
1207
+ """The Hyprland backend (whatever it does with the window geometry):
1208
+ the box is resized through its IPC, and its `maximized` state is the
1209
+ feed's flag, not GLFW's."""
1210
+ import meltygui.core.windowing.geometry_feed as geometry_feed
1211
+ return geometry_feed.backend() == "hyprland"
1212
+
1213
+
1214
+ def _box_is_surface():
1215
+ """A Hyprland that does NOT honour the xdg window geometry: the
1216
+ compositor's window box IS the surface — it renders the whole surface
1217
+ at `at` and ignores the geometry for placement (geometry_feed's module
1218
+ docstring). Every place that reads a configure as a GEOMETRY size, or
1219
+ resizes by committing a buffer, branches on this. The patched
1220
+ compositor (`render:xdg_window_geometry`, 09-09) makes the box the
1221
+ CONTENT — then this is False and the GNOME handling applies: the
1222
+ geometry is the content rect, a configure names the content and the
1223
+ surface is regrown around it by the margin (geometry_feed
1224
+ .hypr_honors_geometry)."""
1225
+ import meltygui.core.windowing.geometry_feed as geometry_feed
1226
+ return geometry_feed.backend() == "hyprland" and not geometry_feed.hypr_honors_geometry()
1227
+
1228
+
1229
+ def set_surface_size(window, width, height, offset=None, box=True):
1230
+ """The one way this module resizes the OS surface: flags the resulting
1231
+ framebuffer-size callback as ours, so on_surface_resized leaves it
1232
+ alone (a compositor configure would be grown by the margin). On
1233
+ Hyprland the box is ALSO resized through its IPC — the buffer alone
1234
+ leaves the box where it was — and ``offset`` (dx, dy), the move that
1235
+ rides the same commit elsewhere, goes into that SAME request
1236
+ (geometry_feed.hypr_set_box: resize + move anchored top-left, one
1237
+ eval); other backends ignore ``offset`` here and arm the buffer
1238
+ offset themselves (apply_pending_surface_size). On a Hyprland that
1239
+ honours the window geometry the box is the CONTENT: the IPC gets the
1240
+ surface size less the shadow margin on every side. ``box=False``
1241
+ (on_surface_resized's regrow after a compositor configure) skips the
1242
+ IPC: the box already has that size, and asking again would configure
1243
+ the same size back at GLFW and regrow once more, forever."""
1244
+ global _self_resize
1245
+ _self_resize = True
1246
+ try:
1247
+ glfw.set_window_size(window, int(width), int(height))
1248
+ finally:
1249
+ _self_resize = False
1250
+ if box and _on_hyprland():
1251
+ import meltygui.core.windowing.geometry_feed as geometry_feed
1252
+ dx, dy = offset if offset else (0, 0)
1253
+ box_w, box_h = int(width), int(height)
1254
+ if geometry_feed.hypr_honors_geometry():
1255
+ inset = int(window_inset())
1256
+ box_w, box_h = max(1, box_w - 2 * inset), max(1, box_h - 2 * inset)
1257
+ geometry_feed.hypr_set_box(box_w, box_h, dx, dy)
1258
+
1259
+
1260
+ # An app-side resize requested mid-frame, applied at the next frame's start.
1261
+ _pending_surface_size = globals().get("_pending_surface_size")
1262
+
1263
+
1264
+ _pending_surface_offset = globals().get("_pending_surface_offset")
1265
+ _frame_surface_offset = globals().get("_frame_surface_offset")
1266
+ # The queued request also wants the CONTENT fitted into the work area
1267
+ # (the launch restore - request_surface_size(fit=True)).
1268
+ _pending_surface_fit = globals().get("_pending_surface_fit") or False
1269
+ # Frames a request has waited for the Hyprland feed to see the window (its
1270
+ # `address:` selector): the launch restore is filed before the feed's
1271
+ # first poll. Bounded so a dead feed can't hold a request forever.
1272
+ _pending_surface_wait = globals().get("_pending_surface_wait") or 0
1273
+ PENDING_SURFACE_WAIT_FRAMES = 300
1274
+
1275
+
1276
+ def request_surface_size(window, width, height, offset=None, fit=False):
1277
+ """Queue an app-side resize (the right-drag) for the top of the NEXT
1278
+ frame (apply_pending_surface_size, before process_inputs). Applied
1279
+ mid-frame it committed a new buffer size and geometry with content laid
1280
+ out for the old size — one frame of jelly on every drag step. Later
1281
+ requests in the same frame replace earlier ones. ``offset`` = (dx, dy)
1282
+ px to MOVE the window by in the same commit (the buffer offset of the
1283
+ swap's attach — wayland_move.set_surface_offset; the near-edge push:
1284
+ grow right by d and move left by d = the left edge moved out).
1285
+ ``fit`` (Hyprland, the launch restore): add to the move whatever keeps
1286
+ the CONTENT inside the work area at the new size — the compositor
1287
+ centred the window at its INITIAL size and the box grows anchored at
1288
+ its top-left, so a restored studio ran off the bottom and right of
1289
+ the screen (and the OS-edge walls then hold it there, 09-09)."""
1290
+ global _pending_surface_size, _pending_surface_offset, _pending_surface_fit
1291
+ _pending_surface_size = (int(width), int(height))
1292
+ _pending_surface_offset = tuple(int(v) for v in offset) if offset else None
1293
+ _pending_surface_fit = bool(fit)
1294
+
1295
+
1296
+ def fit_offset(rect, size, area, inset):
1297
+ """(dx, dy) that moves a surface at ``rect`` (x, y, w, h on screen) to
1298
+ where its CONTENT — the surface at its new ``size`` less ``inset`` px
1299
+ of shadow margin on every side — lies inside the work ``area`` (x, y,
1300
+ w, h). A content larger than the area keeps its near (top / left)
1301
+ edge on the area's."""
1302
+ out = []
1303
+ for i in (0, 1):
1304
+ near = rect[i] + inset
1305
+ far = rect[i] + size[i] - inset
1306
+ d = min(0, (area[i] + area[i + 2]) - far) # pull back past the far edge
1307
+ d = max(d, area[i] - near) # never past the near one
1308
+ out.append(int(d))
1309
+ return tuple(out)
1310
+
1311
+
1312
+ def apply_pending_surface_size(window):
1313
+ """LSDStudio's loop, before process_inputs: apply the queued resize so
1314
+ this frame lays out at the new size. Returns the size applied."""
1315
+ global _pending_surface_size, _pending_surface_offset, _frame_surface_offset
1316
+ global _pending_surface_wait, _pending_surface_fit
1317
+ import meltygui.core.windowing.wayland_move as wayland_move
1318
+ import meltygui.core.windowing.os_frame as os_frame
1319
+ wayland_move.clear_surface_offset() # last frame's offset is spent
1320
+ # The roots' passes re-base with the OS near edge's motion lands HERE,
1321
+ # with the move it compensates (os_frame: the solve booked it).
1322
+ os_frame.apply_rebase()
1323
+ size = _pending_surface_size
1324
+ if size is None or window is None:
1325
+ return None
1326
+ offset = _pending_surface_offset
1327
+ import meltygui.core.windowing.geometry_feed as geometry_feed
1328
+ if (geometry_feed.backend() == "hyprland" and geometry_feed._hypr_selector() is None
1329
+ and _pending_surface_wait < PENDING_SURFACE_WAIT_FRAMES):
1330
+ # Hyprland resizes the box only through its IPC, addressed by the
1331
+ # window handle the feed reports - before its first poll (the
1332
+ # launch restore, LSDStudio's create-window path) nothing can be
1333
+ # sent, so the request holds for the next frame. Applying it now
1334
+ # spent it on a bare glfw.set_window_size Hyprland ignores.
1335
+ _pending_surface_wait += 1
1336
+ # An app renders on request only (app.py's loop): with nothing
1337
+ # else asking, the held request never got its retry frame and a
1338
+ # @glfw_window stayed at Hyprland's default floating size until the
1339
+ # first hover (09-12). Ask for the frame that retries it.
1340
+ from meltygui.core.windowing.glfw_utils import request_render
1341
+ request_render()
1342
+ return None
1343
+ _pending_surface_wait = 0
1344
+ if _pending_surface_fit and geometry_feed.backend() == "hyprland":
1345
+ rect, area = geometry_feed.frame_rect(), geometry_feed.workarea()
1346
+ if rect is not None and area is not None:
1347
+ inset = int(window_inset())
1348
+ if geometry_feed.hypr_honors_geometry():
1349
+ # the feed's rect and the box are the CONTENT: fit the surface less its margin
1350
+ dx, dy = fit_offset(rect, (size[0] - 2 * inset, size[1] - 2 * inset), area, 0)
1351
+ else:
1352
+ dx, dy = fit_offset(rect, size, area, inset)
1353
+ if dx or dy:
1354
+ ox, oy = offset or (0, 0)
1355
+ offset = (ox + dx, oy + dy)
1356
+ os_frame.expect_own_move(dx, dy) # ours, not the compositor's
1357
+ _pending_surface_fit = False
1358
+ _pending_surface_size = None
1359
+ _pending_surface_offset = None
1360
+ from meltygui.core.runtime.toggles import Toggles
1361
+ if Toggles.Melty.push_os_window_edges_trace:
1362
+ try:
1363
+ was = glfw.get_framebuffer_size(window)
1364
+ except Exception:
1365
+ was = None
1366
+ print(f"[os_frame] apply pending surface size {size} (was {was}) offset {offset}")
1367
+ import meltygui.core.windowing.geometry_feed as geometry_feed
1368
+ on_hyprland = _on_hyprland()
1369
+ # Hyprland ignores a toplevel's buffer offset (the surface stays
1370
+ # anchored at `at`): the move rides the box resize's own IPC request.
1371
+ set_surface_size(window, *size, offset=offset if on_hyprland else None)
1372
+ # The move rides the same commit: armed HERE, right after GLFW's resize
1373
+ # (which reset the EGL window's offset to 0) and before anything is
1374
+ # drawn. Arming it late - right before the swap - blanked every frame
1375
+ # that carried a move: wl_egl_window_resize flags the EGL surface as
1376
+ # resized, the driver re-creates its swapchain for that swap, and the
1377
+ # frame already rendered on the old buffers was lost (the window
1378
+ # vanished while the hand moved, reappeared at random). At frame start
1379
+ # the driver's resize handling runs BEFORE the frame is drawn, exactly
1380
+ # as for GLFW's own resizes. Nothing else touches the EGL window until
1381
+ # the swap.
1382
+ _frame_surface_offset = None
1383
+ if offset and (offset[0] or offset[1]) and not on_hyprland:
1384
+ wayland_move.set_surface_offset(offset[0], offset[1])
1385
+ return size
1386
+
1387
+
1388
+ _last_stamped_size = globals().get("_last_stamped_size")
1389
+
1390
+
1391
+ def on_surface_resized(window, width, height):
1392
+ """LSDStudio's framebuffer-size callback hook. With the window geometry
1393
+ inset to the content, a compositor configure (interactive resize from
1394
+ the edge zones, un-maximize, tiling) names a GEOMETRY size — GLFW makes
1395
+ the SURFACE that size, which would shrink the content by twice the
1396
+ margin. Grow the surface back right here, inside the callback, so the
1397
+ frame that follows is already right (a deferred fix would jitter the
1398
+ content edge on every configure of a drag). Our own resizes
1399
+ (set_surface_size) are flagged and pass through; maximized/fullscreen
1400
+ have no margin and pass through too. Returns the size applied."""
1401
+ global _last_surface_size, _last_stamped_size
1402
+ size = (int(width), int(height))
1403
+ if size != _last_stamped_size:
1404
+ # Any backend, ours or the compositor's: stamp the gesture for
1405
+ # freeze_resize views (Melty.resize_gesture_live); the cache keeps
1406
+ # frames coming past the last configure until they settle.
1407
+ from meltygui.core.melty import Melty
1408
+ from meltygui.core.windowing.glfw_utils import request_render
1409
+ _last_stamped_size = size
1410
+ Melty.os_resize_time = time.monotonic()
1411
+ request_render()
1412
+ if not _on_wayland() or not wayland_move.geometry_available():
1413
+ return None
1414
+ if _self_resize:
1415
+ # Our resize (the right-drag, a compensation): the geometry rides in
1416
+ # the SAME commit as the new buffer. If from draw_titlebar a frame
1417
+ # later it lagged one drag step behind - Mutter pushed the window
1418
+ # up against a stale rect (jitter) and the last step's growth never
1419
+ # got the push (a gap at the top).
1420
+ _last_surface_size = size
1421
+ sync_window_geometry(window, size)
1422
+ return None
1423
+ if size == _last_surface_size:
1424
+ # GLFW re-fired the callback with the size it already had (output
1425
+ # resize, fractional scale): no configure, nothing to regrow.
1426
+ return None
1427
+ _last_surface_size = size
1428
+ inset = window_inset() # MAXIMIZED is already current inside GLFW's configure handling
1429
+ # A compositor-driven size names the GEOMETRY (the content): the
1430
+ # surface is regrown outside it by the margin on every side. The OS
1431
+ # edge model (os_frame.begin_frame) reads the new content size next
1432
+ # frame and folds it into the roots' passes as the near edge's motion.
1433
+ # Hyprland's configure names the SURFACE (the box): never regrow.
1434
+ if inset <= 0 or _box_is_surface():
1435
+ sync_window_geometry(window, (int(width), int(height)))
1436
+ return None
1437
+ grown = (int(width) + 2 * inset, int(height) + 2 * inset)
1438
+ set_surface_size(window, *grown, box=False) # its nested callback syncs the geometry; the box IS this size
1439
+ return grown
1440
+
1441
+
1442
+ def sync_window_geometry(window, size=None):
1443
+ """Wayland: keep xdg_surface.set_window_geometry on the CONTENT rect
1444
+ (the surface minus the shadow margin), the whole surface without one —
1445
+ re-marshalled only when it changes. From on_surface_resized with the
1446
+ new size (same commit as the buffer), and per frame from draw_titlebar
1447
+ as the catch-all (margin toggled, maximize state)."""
1448
+ global _geometry_applied
1449
+ if not _on_wayland() or not wayland_move.geometry_available():
1450
+ return False
1451
+ fb_w, fb_h = size if size is not None else glfw.get_framebuffer_size(window)
1452
+ inset = int(window_inset())
1453
+ if _box_is_surface():
1454
+ # Hyprland renders the surface at the box and ADDS the surface
1455
+ # origin to pointer coordinates (ViewHitTester.cpp) so an inset
1456
+ # geometry shifts every click by the margin. The whole surface.
1457
+ inset = 0
1458
+ rect = (inset, inset, max(1, int(fb_w) - 2 * inset), max(1, int(fb_h) - 2 * inset))
1459
+ if rect == _geometry_applied:
1460
+ return False
1461
+ if wayland_move.set_window_geometry(*rect):
1462
+ _geometry_applied = rect
1463
+ return True
1464
+ return False
1465
+
1466
+
1467
+ def sync_input_region(window):
1468
+ """Wayland: keep the surface's input region on the CONTENT rect so
1469
+ clicks in the transparent shadow margin fall through to whatever is
1470
+ behind (wayland_move.set_input_rect); the whole surface when there is
1471
+ no margin. Re-marshalled only when the rect changes."""
1472
+ global _input_rect_applied
1473
+ if not _on_wayland() or not wayland_move.input_region_available():
1474
+ return False
1475
+ inset = window_inset()
1476
+ ox, oy = content_origin()
1477
+ if inset <= 0 and ox == 0 and oy == 0:
1478
+ rect = None
1479
+ else:
1480
+ fb_w, fb_h = glfw.get_framebuffer_size(window)
1481
+ rect = (int(ox), int(oy), max(1, int(fb_w - ox - inset)), max(1, int(fb_h - oy - inset)))
1482
+ if rect == _input_rect_applied:
1483
+ return False
1484
+ if wayland_move.set_input_rect(rect):
1485
+ _input_rect_applied = rect
1486
+ return True
1487
+ return False
1488
+
1489
+
1490
+ def composite_window_frame(fb_w, fb_h):
1491
+ """Last GL work of the frame (Melty.post_frame, after the overlay): the
1492
+ frameless window's ALPHA, one fullscreen pass over the REAL framebuffer
1493
+ (fb_w × fb_h, the content inset window_inset() px) from the content's
1494
+ rounded-rect coverage `cov` (1 inside, 0 outside, a one-pixel ramp).
1495
+
1496
+ With the shadow pass on (Toggles.filters), ShadowComposite has already
1497
+ rewritten everything outside the content as the shadow in premultiplied
1498
+ alpha — the OS window's shadow is that pass, nothing else draws one —
1499
+ so this only lifts the CONTENT's alpha back to 1: imgui's own blend
1500
+ leaves a translucent draw at dst_a = a² + dst_a·(1−a) < 1, through
1501
+ which the desktop would bleed. MAX-blended alpha, rgb untouched.
1502
+
1503
+ Without it (no composite ran) the pass PREMULTIPLIES by the coverage
1504
+ instead: rgb·cov, alpha = cov. Wayland composites premultiplied alpha,
1505
+ so alpha 0 alone is not invisible — a view drawn over a cut corner, or
1506
+ the brightness pass lifting the cleared black, would be ADDED onto the
1507
+ desktop. No-op unless the window was created transparent; it runs
1508
+ maximized too (radius and inset 0), where it is purely the alpha lift."""
1509
+ global _corner_gl
1510
+ from meltygui.core.runtime.toggles import Toggles
1511
+ radius = frame_corner_radius()
1512
+ inset = float(window_inset())
1513
+ origin = content_origin()
1514
+ if fb_w <= 0 or fb_h <= 0 or not is_gl_thread():
1515
+ return False
1516
+ if not _frame_transparent(_studio_window()):
1517
+ return False
1518
+ # No early-out on radius/inset/origin all zero (maximized): the window
1519
+ # was created with an alpha channel, so the content's alpha must still
1520
+ # be lifted to 1 - the compositor blends the desktop through anything
1521
+ # below 1 whatever the corners look like.
1522
+ content_size = (float(fb_w) - origin[0] - inset, float(fb_h) - origin[1] - inset)
1523
+ if _corner_gl is None:
1524
+ _corner_gl = GLState()
1525
+ saved_mask = gl.glGetBooleanv(gl.GL_COLOR_WRITEMASK)
1526
+ blend = gl.glIsEnabled(gl.GL_BLEND)
1527
+ blend_func = (int(gl.glGetIntegerv(gl.GL_BLEND_SRC_RGB)), int(gl.glGetIntegerv(gl.GL_BLEND_DST_RGB)),
1528
+ int(gl.glGetIntegerv(gl.GL_BLEND_SRC_ALPHA)), int(gl.glGetIntegerv(gl.GL_BLEND_DST_ALPHA)))
1529
+ blend_eq = (int(gl.glGetIntegerv(gl.GL_BLEND_EQUATION_RGB)), int(gl.glGetIntegerv(gl.GL_BLEND_EQUATION_ALPHA)))
1530
+ scissor = gl.glIsEnabled(gl.GL_SCISSOR_TEST)
1531
+ depth = gl.glIsEnabled(gl.GL_DEPTH_TEST)
1532
+ stencil = gl.glIsEnabled(gl.GL_STENCIL_TEST)
1533
+ try:
1534
+ from meltygui.core.melty import Melty
1535
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, Melty.default_framebuffer())
1536
+ gl.glViewport(0, 0, int(fb_w), int(fb_h))
1537
+ gl.glDisable(gl.GL_SCISSOR_TEST)
1538
+ gl.glDisable(gl.GL_DEPTH_TEST)
1539
+ gl.glDisable(gl.GL_STENCIL_TEST)
1540
+ gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE)
1541
+ gl.glEnable(gl.GL_BLEND)
1542
+ if Toggles.filters:
1543
+ # alpha = max(dst_a, cov); rgb = dst
1544
+ gl.glBlendEquationSeparate(gl.GL_FUNC_ADD, gl.GL_MAX)
1545
+ gl.glBlendFuncSeparate(gl.GL_ZERO, gl.GL_ONE, gl.GL_ONE, gl.GL_ONE)
1546
+ else:
1547
+ # rgb = 0·src + dst·src_a ; alpha = src_a·1 + dst_a·0
1548
+ gl.glBlendEquationSeparate(gl.GL_FUNC_ADD, gl.GL_FUNC_ADD)
1549
+ gl.glBlendFuncSeparate(gl.GL_ZERO, gl.GL_SRC_ALPHA, gl.GL_ONE, gl.GL_ZERO)
1550
+ _corner_alpha_pass(_corner_gl, content_size=content_size, origin=origin, radius=radius)
1551
+ return True
1552
+ finally:
1553
+ gl.glColorMask(*[gl.GL_TRUE if bool(m) else gl.GL_FALSE for m in saved_mask])
1554
+ gl.glBlendFuncSeparate(*blend_func)
1555
+ gl.glBlendEquationSeparate(*blend_eq)
1556
+ (gl.glEnable if blend else gl.glDisable)(gl.GL_BLEND)
1557
+ (gl.glEnable if scissor else gl.glDisable)(gl.GL_SCISSOR_TEST)
1558
+ (gl.glEnable if depth else gl.glDisable)(gl.GL_DEPTH_TEST)
1559
+ (gl.glEnable if stencil else gl.glDisable)(gl.GL_STENCIL_TEST)
1560
+ gl.glBindVertexArray(0)