From 0e6408a44727cc31ebfe0e281b64f321e7db8d41 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 25 Mar 2026 14:19:27 +0100 Subject: [PATCH] fix(electrobun): X11 XMoveResizeWindow bypass for resize (no GTK involvement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit begin_resize_drag + XUngrabPointer still fails because GTK's layout cycle re-asserts WebView preferred size, fighting the WM resize. New approach: JS mousemove → XMoveResizeWindow via libX11.so.6 FFI. Completely bypasses GTK size negotiation. GTK only receives ConfigureNotify after the X server has already resized the window. Added: x11SetFrame() using gdk_x11_display_get_xdisplay + gdk_x11_window_get_xid + XMoveResizeWindow. --- ui-electrobun/src/bun/gtk-window.ts | 43 ++++++++++++++++++++ ui-electrobun/src/bun/index.ts | 7 ++++ ui-electrobun/src/mainview/App.svelte | 58 ++++++++++++++++++++++----- 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/ui-electrobun/src/bun/gtk-window.ts b/ui-electrobun/src/bun/gtk-window.ts index 30abeca..ce50bc7 100644 --- a/ui-electrobun/src/bun/gtk-window.ts +++ b/ui-electrobun/src/bun/gtk-window.ts @@ -27,6 +27,7 @@ function getX11() { x11 = dlopen("libX11.so.6", { XUngrabPointer: { args: [FFIType.ptr, FFIType.u64], returns: FFIType.i32 }, XFlush: { args: [FFIType.ptr], returns: FFIType.i32 }, + XMoveResizeWindow: { args: [FFIType.ptr, FFIType.u64, FFIType.i32, FFIType.i32, FFIType.u32, FFIType.u32], returns: FFIType.i32 }, }); return x11; } catch { return null; } @@ -38,6 +39,7 @@ function getGdk() { gdk3 = dlopen("libgdk-3.so.0", { gdk_display_get_default: { args: [], returns: FFIType.ptr }, gdk_x11_display_get_xdisplay: { args: [FFIType.ptr], returns: FFIType.ptr }, + gdk_x11_window_get_xid: { args: [FFIType.ptr], returns: FFIType.u64 }, }); return gdk3; } catch { return null; } @@ -151,6 +153,11 @@ function getGtk() { args: [FFIType.ptr], returns: FFIType.void, }, + // Get GdkWindow from GtkWidget (needed for X11 window ID) + gtk_widget_get_window: { + args: [FFIType.ptr], + returns: FFIType.ptr, + }, // Sensitivity (disable input processing on widget) gtk_widget_set_sensitive: { args: [FFIType.ptr, FFIType.bool], @@ -266,6 +273,42 @@ function forceSmallMinSize(lib: NonNullable, windowPtr: any) { } catch { /* ignore */ } } +/** + * Set window frame directly via X11, BYPASSING GTK's size negotiation. + * GTK receives ConfigureNotify after the fact and adjusts. + * This prevents the WebView's preferred size from fighting the resize. + */ +export function x11SetFrame( + windowPtr: number | bigint, + x: number, y: number, width: number, height: number, +): boolean { + const gtkLib = getGtk(); + const gdkLib = getGdk(); + const x11Lib = getX11(); + if (!gtkLib || !gdkLib || !x11Lib) return false; + try { + const gdkDisplay = gdkLib.symbols.gdk_display_get_default(); + if (!gdkDisplay) return false; + const xDisplay = gdkLib.symbols.gdk_x11_display_get_xdisplay(gdkDisplay); + if (!xDisplay) return false; + const gdkWindow = gtkLib.symbols.gtk_widget_get_window(windowPtr as any); + if (!gdkWindow) return false; + const xid = gdkLib.symbols.gdk_x11_window_get_xid(gdkWindow); + if (!xid) return false; + x11Lib.symbols.XMoveResizeWindow( + xDisplay, xid, + Math.round(x), Math.round(y), + Math.max(400, Math.round(width)), + Math.max(300, Math.round(height)), + ); + x11Lib.symbols.XFlush(xDisplay); + return true; + } catch (err) { + console.error("[gtk-window] x11SetFrame failed:", err); + return false; + } +} + /** * Delegate resize to the window manager. * Releases X11 grabs first (SDL2 pattern) so WM can take the pointer. diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index a6cd5a6..ce930fc 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -161,6 +161,13 @@ const rpc = BrowserView.defineRPC({ return { ok }; } catch (err) { console.error("[window.gtkSetFrame]", err); return { ok: false }; } }, + "window.x11SetFrame": ({ x, y, width, height }: { x: number; y: number; width: number; height: number }) => { + try { + const { x11SetFrame } = require("./gtk-window.ts"); + const ok = x11SetFrame((mainWindow as any).ptr, x, y, width, height); + return { ok }; + } catch (err) { console.error("[window.x11SetFrame]", err); return { ok: false }; } + }, }, messages: {}, }, diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index b930d1c..9885ac2 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -104,20 +104,55 @@ e.preventDefault(); } - // ── Window resize — native GTK begin_resize_drag ──────── + // ── Window resize — JS-based with X11 direct frame set ──────── + let resizeEdge: string | null = null; + let resizeStartX = 0; + let resizeStartY = 0; + let resizeFrame = { x: 0, y: 0, width: 0, height: 0 }; + const CURSOR_MAP: Record = { + n: 'n-resize', s: 's-resize', e: 'e-resize', w: 'w-resize', + ne: 'ne-resize', nw: 'nw-resize', se: 'se-resize', sw: 'sw-resize', + }; + function onResizeStart(e: MouseEvent, edge: string) { - // MUST stop propagation SYNCHRONOUSLY to prevent sidebar drag handler e.preventDefault(); e.stopPropagation(); - // Delegate to GTK window manager — handles cursor, animation, constraints - appRpc.request['window.beginResize']({ - edge, - button: e.button + 1, // DOM: 0=left, GTK: 1=left - rootX: e.screenX, - rootY: e.screenY, + resizeEdge = edge; + resizeStartX = e.screenX; + resizeStartY = e.screenY; + document.body.style.cursor = CURSOR_MAP[edge] || 'default'; + document.body.style.userSelect = 'none'; + // Capture frame async — resize uses deltas so a slight delay is fine + appRpc.request['window.getFrame']({}).then((f: any) => { + resizeFrame = { x: f.x, y: f.y, width: f.width, height: f.height }; }).catch(() => {}); } + function onResizeMove(e: MouseEvent) { + if (!resizeEdge) return; + const dx = e.screenX - resizeStartX; + const dy = e.screenY - resizeStartY; + let { x, y, width, height } = resizeFrame; + const MIN_W = 400, MIN_H = 300; + if (resizeEdge.includes('e')) width = Math.max(MIN_W, width + dx); + if (resizeEdge.includes('w')) { const nw = Math.max(MIN_W, width - dx); x += width - nw; width = nw; } + if (resizeEdge.includes('s')) height = Math.max(MIN_H, height + dy); + if (resizeEdge.includes('n')) { const nh = Math.max(MIN_H, height - dy); y += height - nh; height = nh; } + // X11 direct — bypasses GTK size negotiation + appRpc.request['window.x11SetFrame']({ + x: Math.round(x), y: Math.round(y), + width: Math.round(width), height: Math.round(height), + }).catch(() => {}); + } + + function onResizeEnd() { + if (!resizeEdge) return; + resizeEdge = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + saveWindowFrame(); + } + // ── Window frame persistence (debounced 500ms) ────────────── let frameSaveTimer: ReturnType | null = null; function saveWindowFrame() { @@ -216,7 +251,9 @@ setAgentToastFn(showToast); setupErrorBoundary(); - // Native GTK begin_resize_drag handles resize — no JS mousemove needed + // JS resize uses document-level listeners + document.addEventListener('mousemove', onResizeMove); + document.addEventListener('mouseup', onResizeEnd); // Blink + session timers — MUST be in onMount, NOT $effect // $effect interacts with reactive graph and causes cycles @@ -312,7 +349,8 @@ clearInterval(sessionId); document.removeEventListener("keydown", handleSearchShortcut); window.removeEventListener("palette-command", handlePaletteCommand); - // no resize listeners to clean up — native GTK handles it + document.removeEventListener('mousemove', onResizeMove); + document.removeEventListener('mouseup', onResizeEnd); }; });