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)
This commit is contained in:
Hibryda 2026-03-25 15:25:25 +01:00
parent c4d06ca999
commit e9fcd8401e
3 changed files with 201 additions and 76 deletions

View file

@ -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<typeof dlopen> | 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)");
}

View file

@ -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.