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:
parent
c4d06ca999
commit
e9fcd8401e
3 changed files with 201 additions and 76 deletions
189
ui-electrobun/src/bun/gtk-resize.ts
Normal file
189
ui-electrobun/src/bun/gtk-resize.ts
Normal 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)");
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<typeof setTimeout> | 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
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
@ -369,23 +322,7 @@
|
|||
onClose={() => setNotifDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
<!-- Resize handles (all edges + corners) -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="rz rz-n" onmousedown={(e) => onResizeStart(e, 'n')}></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="rz rz-s" onmousedown={(e) => onResizeStart(e, 's')}></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="rz rz-e" onmousedown={(e) => onResizeStart(e, 'e')}></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="rz rz-w" onmousedown={(e) => onResizeStart(e, 'w')}></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="rz rz-ne" onmousedown={(e) => onResizeStart(e, 'ne')}></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="rz rz-nw" onmousedown={(e) => onResizeStart(e, 'nw')}></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="rz rz-se" onmousedown={(e) => onResizeStart(e, 'se')}></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="rz rz-sw" onmousedown={(e) => onResizeStart(e, 'sw')}></div>
|
||||
<!-- Resize handled natively by GTK (gtk-resize.ts) — 8px border zone on the GtkWindow -->
|
||||
|
||||
<div class="app-shell" role="presentation">
|
||||
<!-- Left sidebar icon rail — draggable for window move -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue