From e9fcd8401ed75d7d0e1bdc22cc6c55e76806e616 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 25 Mar 2026 15:25:25 +0100 Subject: [PATCH] feat(electrobun): native GTK resize via button-press-event signal (tao pattern) All previous approaches failed because they initiated resize from JS (too late, wrong timestamp, WebKit steals grab). The correct approach (proven by Tauri/tao) is to connect button-press-event DIRECTLY on the GtkWindow at the GTK level, BEFORE WebKitGTK processes events. New: gtk-resize.ts - JSCallback for button-press-event + motion-notify-event - Hit-test in 8px border zone (same as tao's 5*scale_factor) - begin_resize_drag with REAL event timestamp (not GDK_CURRENT_TIME) - Returns TRUE to STOP propagation (WebKit never sees the press) - Cursor updates on motion-notify in border zone Removed: all JS resize handles (divs, mousemove, mouseup, RPC calls) --- ui-electrobun/src/bun/gtk-resize.ts | 189 ++++++++++++++++++++++++++ ui-electrobun/src/bun/index.ts | 13 +- ui-electrobun/src/mainview/App.svelte | 75 +--------- 3 files changed, 201 insertions(+), 76 deletions(-) create mode 100644 ui-electrobun/src/bun/gtk-resize.ts diff --git a/ui-electrobun/src/bun/gtk-resize.ts b/ui-electrobun/src/bun/gtk-resize.ts new file mode 100644 index 0000000..267d8f3 --- /dev/null +++ b/ui-electrobun/src/bun/gtk-resize.ts @@ -0,0 +1,189 @@ +/** + * GTK-native window resize — mirrors Tauri/tao's approach. + * + * Connects button-press-event and motion-notify-event directly on the + * GtkWindow via FFI. Hit-test and begin_resize_drag happen SYNCHRONOUSLY + * in the native GTK event loop, BEFORE WebKitGTK sees the events. + * + * This is the ONLY approach that works for undecorated WebKitGTK windows. + * JS-side approaches (RPC, window.resizeTo, XMoveResizeWindow) all fail + * because WebKitGTK either steals the grab or blocks the resize. + */ + +import { dlopen, FFIType, JSCallback, ptr } from "bun:ffi"; + +const BORDER = 8; // resize border width in pixels + +// GdkWindowEdge values +const EDGE_NW = 0, EDGE_N = 1, EDGE_NE = 2; +const EDGE_W = 3, EDGE_E = 4; +const EDGE_SW = 5, EDGE_S = 6, EDGE_SE = 7; + +// Cursor names indexed by edge +const CURSORS = [ + "nw-resize", "n-resize", "ne-resize", + "w-resize", "", "e-resize", + "sw-resize", "s-resize", "se-resize", +]; + +let lib: ReturnType | null = null; + +function getLib() { + if (lib) return lib; + lib = dlopen("libgtk-3.so.0", { + gtk_widget_add_events: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.void }, + gtk_window_begin_resize_drag: { + args: [FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.u32], + returns: FFIType.void, + }, + gtk_widget_get_window: { args: [FFIType.ptr], returns: FFIType.ptr }, + g_signal_connect_data: { + args: [FFIType.ptr, FFIType.cstring, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.i32], + returns: FFIType.u64, + }, + // GdkWindow methods + gdk_window_get_position: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.void }, + gdk_window_get_width: { args: [FFIType.ptr], returns: FFIType.i32 }, + gdk_window_get_height: { args: [FFIType.ptr], returns: FFIType.i32 }, + // GdkCursor + gdk_cursor_new_from_name: { args: [FFIType.ptr, FFIType.cstring], returns: FFIType.ptr }, + gdk_window_set_cursor: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void }, + gdk_display_get_default: { args: [], returns: FFIType.ptr }, + // GdkEvent field extraction + gdk_event_get_button: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool }, + gdk_event_get_root_coords: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.bool }, + gdk_event_get_time: { args: [FFIType.ptr], returns: FFIType.u32 }, + gdk_event_get_coords: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.bool }, + }); + return lib; +} + +/** + * Hit-test: is the point within BORDER pixels of any edge? + * Returns GdkWindowEdge value (0-7) or -1 if inside content area. + */ +function hitTest( + left: number, top: number, right: number, bottom: number, + cx: number, cy: number, border: number, +): number { + const onLeft = cx < left + border; + const onRight = cx >= right - border; + const onTop = cy < top + border; + const onBottom = cy >= bottom - border; + + if (onTop && onLeft) return EDGE_NW; + if (onTop && onRight) return EDGE_NE; + if (onBottom && onLeft) return EDGE_SW; + if (onBottom && onRight) return EDGE_SE; + if (onTop) return EDGE_N; + if (onBottom) return EDGE_S; + if (onLeft) return EDGE_W; + if (onRight) return EDGE_E; + return -1; +} + +// Keep JSCallback references alive (prevent GC) +let motionCb: JSCallback | null = null; +let buttonCb: JSCallback | null = null; + +/** + * Install native GTK event handlers for resize on an undecorated window. + * Must be called ONCE after window creation. + */ +export function installNativeResize(windowPtr: number | bigint) { + const gtk = getLib(); + if (!gtk) { console.error("[gtk-resize] Failed to load libgtk-3.so.0"); return; } + + // Add required event masks + const GDK_POINTER_MOTION_MASK = 1 << 2; + const GDK_BUTTON_PRESS_MASK = 1 << 8; + const GDK_BUTTON1_MOTION_MASK = 1 << 5; + gtk.symbols.gtk_widget_add_events( + windowPtr as any, + GDK_POINTER_MOTION_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON1_MOTION_MASK, + ); + + // Shared buffers for extracting event fields + const xBuf = new Float64Array(1); + const yBuf = new Float64Array(1); + const btnBuf = new Uint32Array(1); + + function getWindowBounds(): { left: number; top: number; right: number; bottom: number } | null { + const gdkWin = gtk!.symbols.gtk_widget_get_window(windowPtr as any); + if (!gdkWin) return null; + const xArr = new Int32Array(1); + const yArr = new Int32Array(1); + gtk!.symbols.gdk_window_get_position(gdkWin, ptr(xArr.buffer) as any, ptr(yArr.buffer) as any); + const w = gtk!.symbols.gdk_window_get_width(gdkWin); + const h = gtk!.symbols.gdk_window_get_height(gdkWin); + return { left: xArr[0], top: yArr[0], right: xArr[0] + w, bottom: yArr[0] + h }; + } + + // Motion notify — update cursor when hovering over edges + motionCb = new JSCallback( + (_widget: any, event: any, _userData: any) => { + const bounds = getWindowBounds(); + if (!bounds) return 0; + gtk!.symbols.gdk_event_get_root_coords(event, ptr(xBuf.buffer) as any, ptr(yBuf.buffer) as any); + const cx = xBuf[0], cy = yBuf[0]; + const edge = hitTest(bounds.left, bounds.top, bounds.right, bounds.bottom, cx, cy, BORDER); + + const gdkWin = gtk!.symbols.gtk_widget_get_window(windowPtr as any); + if (gdkWin) { + const display = gtk!.symbols.gdk_display_get_default(); + if (display) { + const cursorName = edge >= 0 ? CURSORS[edge] : "default"; + const cursorBuf = Buffer.from(cursorName + "\0"); + const cursor = gtk!.symbols.gdk_cursor_new_from_name(display, ptr(cursorBuf) as any); + gtk!.symbols.gdk_window_set_cursor(gdkWin, cursor); + } + } + return 0; // Proceed — let motion events reach WebKit for hover etc. + }, + { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + ); + + // Button press — initiate resize if in border zone + buttonCb = new JSCallback( + (_widget: any, event: any, _userData: any) => { + gtk!.symbols.gdk_event_get_button(event, ptr(btnBuf.buffer) as any); + if (btnBuf[0] !== 1) return 0; // Only LMB + + gtk!.symbols.gdk_event_get_root_coords(event, ptr(xBuf.buffer) as any, ptr(yBuf.buffer) as any); + const cx = xBuf[0], cy = yBuf[0]; + const bounds = getWindowBounds(); + if (!bounds) return 0; + + const edge = hitTest(bounds.left, bounds.top, bounds.right, bounds.bottom, cx, cy, BORDER); + if (edge < 0) return 0; // Inside content — let WebKit handle it + + const timestamp = gtk!.symbols.gdk_event_get_time(event); + console.log(`[gtk-resize] begin_resize_drag edge=${edge} @${Math.round(cx)},${Math.round(cy)} t=${timestamp}`); + + gtk!.symbols.gtk_window_begin_resize_drag( + windowPtr as any, + edge, + 1, // LMB + Math.round(cx), + Math.round(cy), + timestamp, // REAL event timestamp — not GDK_CURRENT_TIME + ); + + return 1; // TRUE — STOP propagation. WebKit does NOT see this button press. + }, + { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 }, + ); + + // Connect signals — button-press FIRST (higher priority) + // Bun FFI requires Buffer for cstring args, not JS strings + const sigButtonPress = Buffer.from("button-press-event\0"); + const sigMotionNotify = Buffer.from("motion-notify-event\0"); + gtk.symbols.g_signal_connect_data( + windowPtr as any, ptr(sigButtonPress) as any, buttonCb.ptr as any, null, null, 0, + ); + gtk.symbols.g_signal_connect_data( + windowPtr as any, ptr(sigMotionNotify) as any, motionCb.ptr as any, null, null, 0, + ); + + console.log("[gtk-resize] Native resize handlers installed (border=" + BORDER + "px)"); +} diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index ce930fc..c002223 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -240,15 +240,14 @@ mainWindow = new BrowserWindow({ }, }); -// Ensure GTK window is resizable and wrap WebView for proper resize-in +// Install native GTK resize handlers (tao/Tauri pattern) +// This connects button-press-event directly on the GtkWindow, +// handling resize BEFORE WebKitGTK processes events. { - const { ensureResizable, wrapWebViewInScrolledWindow } = require("./gtk-window.ts"); + const { ensureResizable } = require("./gtk-window.ts"); ensureResizable((mainWindow as any).ptr); - // Wrap WebView in GtkScrolledWindow to decouple natural size from window size - // (prevents rubber-band effect when shrinking) - setTimeout(() => { - wrapWebViewInScrolledWindow((mainWindow as any).ptr); - }, 2000); // Delay to ensure WebView is fully initialized + const { installNativeResize } = require("./gtk-resize.ts"); + installNativeResize((mainWindow as any).ptr); } // Prevent GTK's false Ctrl+click detection from closing the window on initial load. diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index 31dcf8b..bb2f19b 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -104,53 +104,9 @@ e.preventDefault(); } - // ── 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) { - e.preventDefault(); - e.stopPropagation(); - resizeEdge = edge; - resizeStartX = e.screenX; - resizeStartY = e.screenY; - document.body.style.cursor = CURSOR_MAP[edge] || 'default'; - document.body.style.userSelect = 'none'; - // Capture frame synchronously from browser — no RPC delay - resizeFrame = { - x: window.screenX, y: window.screenY, - width: window.outerWidth, height: window.outerHeight, - }; - } - - 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; } - // Direct browser API — no RPC, no GTK, no FFI. Works in Chromium, test in WebKitGTK. - window.moveTo(Math.round(x), Math.round(y)); - window.resizeTo(Math.round(width), Math.round(height)); - } - - function onResizeEnd() { - if (!resizeEdge) return; - resizeEdge = null; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - saveWindowFrame(); - } + // ── Window resize handled natively by GTK (see gtk-resize.ts) ── + // No JS resize handlers needed — GTK button-press-event intercepts + // at the native level before WebKitGTK, matching Tauri/tao's approach. // ── Window frame persistence (debounced 500ms) ────────────── let frameSaveTimer: ReturnType | null = null; @@ -250,9 +206,7 @@ setAgentToastFn(showToast); setupErrorBoundary(); - // JS resize uses document-level listeners - document.addEventListener('mousemove', onResizeMove); - document.addEventListener('mouseup', onResizeEnd); + // Resize handled natively by GTK (gtk-resize.ts) — no JS listeners needed // Blink + session timers — MUST be in onMount, NOT $effect // $effect interacts with reactive graph and causes cycles @@ -348,8 +302,7 @@ clearInterval(sessionId); document.removeEventListener("keydown", handleSearchShortcut); window.removeEventListener("palette-command", handlePaletteCommand); - document.removeEventListener('mousemove', onResizeMove); - document.removeEventListener('mouseup', onResizeEnd); + // Resize handled natively — no JS listeners to clean up }; }); @@ -369,23 +322,7 @@ onClose={() => setNotifDrawerOpen(false)} /> - - -
onResizeStart(e, 'n')}>
- -
onResizeStart(e, 's')}>
- -
onResizeStart(e, 'e')}>
- -
onResizeStart(e, 'w')}>
- -
onResizeStart(e, 'ne')}>
- -
onResizeStart(e, 'nw')}>
- -
onResizeStart(e, 'se')}>
- -
onResizeStart(e, 'sw')}>
+