From 9da9d96ebd309a78e1fcd3715dacbcbdb459e500 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 25 Mar 2026 02:23:24 +0100 Subject: [PATCH] feat(electrobun): native GTK drag/resize via gtk_window_begin_resize_drag - gtk-window.ts: FFI wrapper calling libgtk-3.so.0 directly via bun:ffi - begin_resize_drag: delegates resize to window manager (zero CPU, smooth) - begin_move_drag: delegates move to window manager (replaces JS drag) - Removed all JavaScript-based drag/resize logic (no mousemove/mouseup) - RPC: window.beginResize + window.beginMove - Resize handles: 4px edges + 8px corners with proper cursors --- ui-electrobun/src/bun/gtk-window.ts | 105 +++++++++++++++++++++ ui-electrobun/src/bun/index.ts | 18 ++++ ui-electrobun/src/mainview/App.svelte | 87 +++-------------- ui-electrobun/src/shared/pty-rpc-schema.ts | 11 +++ 4 files changed, 149 insertions(+), 72 deletions(-) create mode 100644 ui-electrobun/src/bun/gtk-window.ts diff --git a/ui-electrobun/src/bun/gtk-window.ts b/ui-electrobun/src/bun/gtk-window.ts new file mode 100644 index 0000000..7f5d9b4 --- /dev/null +++ b/ui-electrobun/src/bun/gtk-window.ts @@ -0,0 +1,105 @@ +/** + * GTK3 FFI — direct calls to libgtk-3.so.0 for window management. + * + * Used for begin_resize_drag and begin_move_drag which Electrobun + * doesn't expose natively. These delegate to the window manager + * for smooth, zero-CPU resize/move behavior. + */ + +import { dlopen, FFIType, ptr } from "bun:ffi"; + +// GdkWindowEdge values +export const GDK_EDGE = { + NW: 0, N: 1, NE: 2, + W: 3, E: 4, + SW: 5, S: 6, SE: 7, +} as const; + +type GdkEdge = (typeof GDK_EDGE)[keyof typeof GDK_EDGE]; + +let gtk3: ReturnType | null = null; + +function getGtk() { + if (gtk3) return gtk3; + try { + gtk3 = dlopen("libgtk-3.so.0", { + gtk_window_begin_resize_drag: { + args: [FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.u32], + returns: FFIType.void, + }, + gtk_window_begin_move_drag: { + args: [FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.u32], + returns: FFIType.void, + }, + }); + return gtk3; + } catch (err) { + console.error("[gtk-window] Failed to dlopen libgtk-3.so.0:", err); + return null; + } +} + +/** + * Delegate resize to the window manager. + * The WM handles cursor, animation, constraints — zero CPU from us. + */ +export function beginResizeDrag( + windowPtr: number | bigint, + edge: GdkEdge, + button: number, + rootX: number, + rootY: number, +) { + const lib = getGtk(); + if (!lib) return false; + try { + lib.symbols.gtk_window_begin_resize_drag( + windowPtr as any, + edge, + button, + Math.round(rootX), + Math.round(rootY), + 0, // GDK_CURRENT_TIME + ); + return true; + } catch (err) { + console.error("[gtk-window] begin_resize_drag failed:", err); + return false; + } +} + +/** + * Delegate move to the window manager. + */ +export function beginMoveDrag( + windowPtr: number | bigint, + button: number, + rootX: number, + rootY: number, +) { + const lib = getGtk(); + if (!lib) return false; + try { + lib.symbols.gtk_window_begin_move_drag( + windowPtr as any, + button, + Math.round(rootX), + Math.round(rootY), + 0, + ); + return true; + } catch (err) { + console.error("[gtk-window] begin_move_drag failed:", err); + return false; + } +} + +// Edge string → GDK_EDGE mapping +const EDGE_MAP: Record = { + n: GDK_EDGE.N, s: GDK_EDGE.S, e: GDK_EDGE.E, w: GDK_EDGE.W, + ne: GDK_EDGE.NE, nw: GDK_EDGE.NW, se: GDK_EDGE.SE, sw: GDK_EDGE.SW, +}; + +export function edgeStringToGdk(edge: string): GdkEdge | null { + return EDGE_MAP[edge] ?? null; +} diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index 72ee8bf..f413610 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -108,6 +108,24 @@ const rpc = BrowserView.defineRPC({ ...providerHandlers, ...miscHandlers, + // GTK native drag/resize — delegates to window manager (zero CPU) + "window.beginResize": ({ edge, button, rootX, rootY }: { edge: string; button: number; rootX: number; rootY: number }) => { + try { + const { beginResizeDrag, edgeStringToGdk } = require("./gtk-window.ts"); + const gdkEdge = edgeStringToGdk(edge); + if (gdkEdge === null) return { ok: false, error: `Unknown edge: ${edge}` }; + const ok = beginResizeDrag((mainWindow as any).ptr, gdkEdge, button, rootX, rootY); + return { ok }; + } catch (err) { console.error("[window.beginResize]", err); return { ok: false }; } + }, + "window.beginMove": ({ button, rootX, rootY }: { button: number; rootX: number; rootY: number }) => { + try { + const { beginMoveDrag } = require("./gtk-window.ts"); + const ok = beginMoveDrag((mainWindow as any).ptr, button, rootX, rootY); + return { ok }; + } catch (err) { console.error("[window.beginMove]", err); return { ok: false }; } + }, + // Window controls — need mainWindow closure, stay inline "window.minimize": () => { try { mainWindow.minimize(); return { ok: true }; } catch (err) { console.error("[window.minimize]", err); return { ok: false }; } }, "window.maximize": () => { diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index 2315313..e48c97a 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -91,80 +91,30 @@ appRpc.request["window.minimize"]({}).catch(console.error); } - // ── Window drag (on sidebar/right-bar empty space) ────────── - let isDragging = false; - let dragStartX = 0; - let dragStartY = 0; - let winStartX = 0; - let winStartY = 0; - + // ── Window drag — delegates to GTK window manager ─────────── function onDragStart(e: MouseEvent) { - // Only start drag from the sidebars themselves, not child buttons const target = e.target as HTMLElement; if (target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.closest('button')) return; - isDragging = true; - dragStartX = e.screenX; - dragStartY = e.screenY; - winStartX = cachedFrame.x; - winStartY = cachedFrame.y; + // Delegate to GTK — the WM handles everything (smooth, zero CPU) + appRpc.request['window.beginMove']({ + button: e.button + 1, // DOM: 0=left, GTK: 1=left + rootX: e.screenX, + rootY: e.screenY, + }).catch(() => {}); e.preventDefault(); } - function onDragMove(e: MouseEvent) { - if (!isDragging) return; - const dx = e.screenX - dragStartX; - const dy = e.screenY - dragStartY; - appRpc.request['window.setPosition']({ x: winStartX + dx, y: winStartY + dy }).catch(() => {}); - } - function onDragEnd() { if (isDragging) { updateCachedFrame(); saveWindowFrame(); } isDragging = false; } - - // ── Window resize (edge handles) ─────────────────────────── - let isResizing = false; - let resizeReady = false; - let resizeEdge = ''; - let resizeStartX = 0; - let resizeStartY = 0; - let resizeFrame = { x: 0, y: 0, width: 0, height: 0 }; - // Cache last known frame to avoid async race - let cachedFrame = { x: 100, y: 100, width: 1400, height: 900 }; - const MIN_W = 600; - const MIN_H = 400; - - // Keep cached frame updated on drag/resize end - function updateCachedFrame() { - appRpc.request['window.getFrame']({}).then(f => { cachedFrame = { ...f }; }).catch(() => {}); - } + // ── Window resize — delegates to GTK window manager ──────── function onResizeStart(e: MouseEvent, edge: string) { - resizeEdge = edge; - resizeStartX = e.screenX; - resizeStartY = e.screenY; - resizeFrame = { ...cachedFrame }; - isResizing = true; - resizeReady = true; + appRpc.request['window.beginResize']({ + edge, + button: e.button + 1, + rootX: e.screenX, + rootY: e.screenY, + }).catch(() => {}); e.preventDefault(); e.stopPropagation(); } - let resizeThrottleId = 0; - function onResizeMove(e: MouseEvent) { - if (!isResizing || !resizeReady) return; - // Throttle to ~30fps to avoid overwhelming GTK - const now = Date.now(); - if (now - resizeThrottleId < 33) return; - resizeThrottleId = now; - const dx = e.screenX - resizeStartX; - const dy = e.screenY - resizeStartY; - let { x, y, width, height } = resizeFrame; - if (resizeEdge.includes('e')) width = Math.max(MIN_W, width + dx); - if (resizeEdge.includes('s')) height = Math.max(MIN_H, height + dy); - if (resizeEdge.includes('w')) { const nw = Math.max(MIN_W, width - dx); x = x + (width - nw); width = nw; } - if (resizeEdge.includes('n')) { const nh = Math.max(MIN_H, height - dy); y = y + (height - nh); height = nh; } - appRpc.request['window.setFrame']({ x, y, width, height }).catch(() => {}); - } - function onResizeEnd() { - if (isResizing) { updateCachedFrame(); saveWindowFrame(); } - isResizing = false; - resizeReady = false; - } // ── Window frame persistence (debounced 500ms) ────────────── let frameSaveTimer: ReturnType | null = null; @@ -264,14 +214,7 @@ setAgentToastFn(showToast); setupErrorBoundary(); - // Seed cached frame from actual window position - updateCachedFrame(); - - // Window drag + resize global listeners - const handleGlobalMove = (e: MouseEvent) => { onDragMove(e); onResizeMove(e); }; - const handleGlobalUp = () => { onDragEnd(); onResizeEnd(); }; - window.addEventListener('mousemove', handleGlobalMove); - window.addEventListener('mouseup', handleGlobalUp); + // No global mousemove/mouseup needed — GTK WM handles drag/resize natively // Blink + session timers — MUST be in onMount, NOT $effect // $effect interacts with reactive graph and causes cycles diff --git a/ui-electrobun/src/shared/pty-rpc-schema.ts b/ui-electrobun/src/shared/pty-rpc-schema.ts index 09c87fa..297b2f4 100644 --- a/ui-electrobun/src/shared/pty-rpc-schema.ts +++ b/ui-electrobun/src/shared/pty-rpc-schema.ts @@ -343,6 +343,17 @@ export type PtyRPCRequests = { response: { ok: boolean }; }; + /** Begin native GTK resize drag — delegates to window manager. */ + "window.beginResize": { + params: { edge: string; button: number; rootX: number; rootY: number }; + response: { ok: boolean; error?: string }; + }; + /** Begin native GTK move drag — delegates to window manager. */ + "window.beginMove": { + params: { button: number; rootX: number; rootY: number }; + response: { ok: boolean }; + }; + // ── Keybindings RPC ──────────────────────────────────────────────────────── /** Return all persisted custom keybindings (overrides only). */