From 178e560068abb3e92b64a258b2cb3de74ece954f Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 25 Mar 2026 16:44:51 +0100 Subject: [PATCH] feat(electrobun): native C resize library (Wails pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New: agor-pty/native/agor_resize.c — C shared library that: - Connects button-press-event on GtkWindow + WebView children in C - Stores mouseButton, xroot, yroot, dragTime from real GTK events - Exports agor_resize_start(edge) for Bun FFI Build: gcc -shared -fPIC -o libagor-resize.so agor_resize.c $(pkg-config --cflags --libs gtk+-3.0) New: ui-electrobun/src/bun/native-resize.ts — Bun FFI wrapper App.svelte: simplified to just edge detection + RPC call --- agor-pty/native/agor_resize.c | 134 +++++++++++++++++++++++++ agor-pty/native/libagor-resize.so | Bin 0 -> 16424 bytes ui-electrobun/src/bun/index.ts | 20 ++-- ui-electrobun/src/bun/native-resize.ts | 94 +++++++++++++++++ ui-electrobun/src/mainview/App.svelte | 58 ++--------- 5 files changed, 244 insertions(+), 62 deletions(-) create mode 100644 agor-pty/native/agor_resize.c create mode 100755 agor-pty/native/libagor-resize.so create mode 100644 ui-electrobun/src/bun/native-resize.ts diff --git a/agor-pty/native/agor_resize.c b/agor-pty/native/agor_resize.c new file mode 100644 index 0000000..c9b4a0e --- /dev/null +++ b/agor-pty/native/agor_resize.c @@ -0,0 +1,134 @@ +/** + * libagor-resize.so — Native GTK resize handler for Electrobun. + * + * Connects button-press-event on the GtkWindow in C (not JSCallback). + * Stores mouse state from the real GTK event. Exports start_resize(edge) + * for Bun FFI to call — uses the stored event data for begin_resize_drag. + * + * This is the Wails pattern adapted for Electrobun/Bun. + * + * Build: gcc -shared -fPIC -o libagor-resize.so agor_resize.c \ + * $(pkg-config --cflags --libs gtk+-3.0) + */ + +#include +#include +#include + +/* Stored mouse state from the last button-press event */ +static gdouble stored_xroot = 0.0; +static gdouble stored_yroot = 0.0; +static guint32 stored_time = 0; +static guint stored_button = 1; +static GtkWindow *stored_window = NULL; + +/* Border width for hit-test (pixels) */ +static int border_width = 8; + +/** + * GTK button-press-event handler — stores mouse state for later use. + * Connected directly in C — no JSCallback boundary crossing. + * Returns FALSE to let the event propagate to WebKitGTK normally. + */ +static gboolean +on_button_press(GtkWidget *widget, GdkEventButton *event, gpointer user_data) +{ + if (event == NULL) return FALSE; + if (event->button == 3) return FALSE; /* Skip right-click */ + + if (event->type == GDK_BUTTON_PRESS && event->button == 1) { + stored_xroot = event->x_root; + stored_yroot = event->y_root; + stored_time = event->time; + stored_button = event->button; + } + return FALSE; /* Propagate — WebKitGTK still processes the event */ +} + +/** + * Initialize: connect button-press-event on the GtkWindow. + * Call ONCE after window creation from Bun FFI. + * + * @param window_ptr GtkWindow* (from Electrobun's mainWindow.ptr) + * @param border Border width in pixels for edge detection + */ +void agor_resize_init(void *window_ptr, int border) +{ + if (!GTK_IS_WINDOW(window_ptr)) { + fprintf(stderr, "[agor-resize] ERROR: not a GtkWindow: %p\n", window_ptr); + return; + } + + stored_window = GTK_WINDOW(window_ptr); + border_width = border > 0 ? border : 8; + + /* Add event masks */ + gtk_widget_add_events(GTK_WIDGET(stored_window), + GDK_POINTER_MOTION_MASK | + GDK_BUTTON_PRESS_MASK | + GDK_BUTTON1_MOTION_MASK); + + /* Connect button-press on the window — fires even when WebView covers everything + because we also connect on the WebView child below */ + g_signal_connect(stored_window, "button-press-event", + G_CALLBACK(on_button_press), NULL); + + /* Walk down to find the WebView and connect there too */ + GtkWidget *child = gtk_bin_get_child(GTK_BIN(stored_window)); + while (child && GTK_IS_BIN(child)) { + g_signal_connect(child, "button-press-event", + G_CALLBACK(on_button_press), NULL); + child = gtk_bin_get_child(GTK_BIN(child)); + } + + fprintf(stderr, "[agor-resize] Initialized: window=%p border=%d\n", + window_ptr, border_width); +} + +/** + * Start a resize drag using the stored mouse state. + * Call from Bun FFI when JS detects a mousedown in the border zone. + * + * @param edge GdkWindowEdge value (0=NW, 1=N, 2=NE, 3=W, 4=E, 5=SW, 6=S, 7=SE) + * @return 1 on success, 0 on failure + */ +int agor_resize_start(int edge) +{ + if (!stored_window) { + fprintf(stderr, "[agor-resize] ERROR: not initialized\n"); + return 0; + } + if (edge < 0 || edge > 7) { + fprintf(stderr, "[agor-resize] ERROR: invalid edge %d\n", edge); + return 0; + } + if (stored_time == 0) { + fprintf(stderr, "[agor-resize] ERROR: no stored event (click first)\n"); + return 0; + } + + fprintf(stderr, "[agor-resize] begin_resize_drag edge=%d btn=%u xy=(%.0f,%.0f) t=%u\n", + edge, stored_button, stored_xroot, stored_yroot, stored_time); + + gtk_window_begin_resize_drag( + stored_window, + (GdkWindowEdge)edge, + stored_button, + (gint)stored_xroot, + (gint)stored_yroot, + stored_time + ); + + return 1; +} + +/** + * Get the stored mouse position (for debugging). + */ +void agor_resize_get_state(double *out_x, double *out_y, unsigned int *out_time, unsigned int *out_button) +{ + if (out_x) *out_x = stored_xroot; + if (out_y) *out_y = stored_yroot; + if (out_time) *out_time = stored_time; + if (out_button) *out_button = stored_button; +} diff --git a/agor-pty/native/libagor-resize.so b/agor-pty/native/libagor-resize.so new file mode 100755 index 0000000000000000000000000000000000000000..cc3db4e8bdfc98cae0698edebde963ece3f32e52 GIT binary patch literal 16424 zcmeHO4Qx}_6}}0$1PIvefVR+$2PJA~iJS7%@t5I{I6PMV$_S(C(8IrpOJb+?Gb9KY zY6Qczm{_}3h*lcgx=BpiRJC+s)z*g6LE5fU=qRJwDn;thY>~BT+D&Pf;+=EvInK+& zuBNG)G|4xz&pqEg=iGD8yYJ;b-+R98UDs4zRwlTN6JHafoDd#rs9werh%FTp;rli* zOUjNPZMv>@qC!DTg-h|+hXbp`+v)rplpIrTM?Kk}rrL##ZYftdQ>z}D?5k1jeRX5$ zG1BWH%6iD6UO(yelU|-uu=hN z>)j7}6f$c0e8OShO#Ky$OD*}&l!htCHx0TN2cIvunh-#@Zp-+h^N zTNgh1K6-%dH^K+?(7$7i+CvpMT9VnmiAcGNSMC3X&Ay-yJfu*48=n&k%SqFZ&}~a z7K?_q`21}l)mFI02z2_4a3tz$i|h#rUrQ`*#6tNCtcfHXKBwqlRT?67vOvMrc8G%?d8VZ<3&}aIjWBy2VtTF?E)<|1$%wS}cM0j^RV!{AM2jz!wMR~-* zxUXf57J3GnpwbrcgI3KVcOvGlm0ZmK5I9q_&|RCy0$96O7$NDQVfYgXb$I}a2F03n zYgacI3*8IdOW+!oS^moN^>TrK);6qTL3$Lb{2WWpj7$_D`y9;^e!lbb_ypY~m+AS< z_zuFid8ApvIP*QL6pqg>e3o(Pwczsq7&Y`+aMa;)*@91C5NN*zue9L8qZpK>AxWUZ zg3~Q0ahC<>{|VS|iv{PgA+A|)2wc8YTkuJFRESy&KG}jVwct}MxW|G|wcxr1cUtgG z7M!oCrMXlsMW7UcQUpp7C`F(YfiExudiOwuo_4%&w-EZl9yWZprJ= zl&ktZd^=}*poV-awrBfuP^w-*9+xQDKFPm`JT6VLJ(7P8d0aYWpOyU2kjEuT_Jrhr zggh>xvPUI<9C=(iWe-UHG30R>l zpBWyfmz~ouTr;PEfrDhABG(^w&Tbsl|H%23PY=|62F)UQSF_$-=f|p^x?)!9>AFk6 zWF7!FGeP(|=Xe|VvhC1!zJJuu?uN!@(9u)-hV@i(P){{y^zMCwB01qSI(v1ep7su= zJ&U`C$1$``LZW+kd{WQ1mL*qWeaPwZc!r!eI-<}Y13CgB0@9voDaVdHvpD5g1dQx# z`&sEq_rNA_=X5H03JjdqQ{MiJ>sl@cmCN8-zer9z?;VlOtpI_3JuL%#E7yTt=DjS1 zaPC~4p{JWK%f{3tJ#{{F@v~em=R%qlZKj}dPO-UO`Vj8<2V@T-gipucci_it~yTR6oN4Vnpwj-jwJS?JctAM z(mXJU?)(HL(vGdrT>onvRlV$2R`ny(4IKHACGWrr9;NR9>5M;bR2wz=WeJ|o{BA^s zYVhg?aCESc$x3DN5ShFP3K=Jx^uiR7V|!EDIiE?AJvl4P7BmW1qA&BV!g~Sllb2)w zoxh5gq|b6mWL;j8Uc4j&`AgC(1yx`5U@PSvNP7nk_BanV!kkP`nX>>!oOV2U7ZSQS zOEU0+kiRnbf!1q0Wn8I!BOvZXEx4|=P=AnAQ)gbR0@q%`uHo*ysBC0L{*}wgP-h*` z(!Fm)blw2p6ZX47cec_^H;=%ScwK6YPKmejns0!~wBs=lrau36r*aqiTx99r3jQFP>R6+Z3G^~J+2zH$F*JaZr;3c^Kva3 zGc}*K#@zW$x!1K^tLm5}{7KV{MQb`hHc=xtqj1n;_1D5CSw!xg1($1TqpYr~L-WVt zun|^Q6`W*~xAcemYot2qI=dtniS7d9K`jKEVj5br_n)_?CCpen1d_OErd0>pB7vP+ zI1*2o^HIyLciq01>;fFt{ARSSDyemL)m2xyYr_lhYrbXzHc6Dt8nrq8!2PUw zfZYb4!(gumsF><%ntIFEos)N091yE!uDEm2ytxRYKK#reH0aN|uY zAC~qJDwR?MN)aeUpcH{p1WFMoMW7UcQUpp7_}@f;&$;orHrz145~T|Bdu=?vdtj+l zB!94O3h~+uRk@XTK3_MBczy?&Nj#sk!}kd+-2RVixft?0a9;!ppPlQ)9bqVZX6^*F ztG6}U7lQW5QVPzs5#=*^89GNtYNCS1!|z#q=I&V1 z(Nb5FFwrK+i;$i!)zU- z{4u;x`GN$EsC`hoFM}@_okmE-8Pb;QNAc zJfB_2{~t^Fn?;95N%FjVmhes+{#(Ki5Kgy?cunFn#10$&Cz3x*INQHM@&$^ zZUiu)AOU#?!I;s~7W2cA{2=`32P`~0br+%Up>i1d5 zYzUgI0+J|{kRgBS1z(yr*RS^)-VKeIS7AW5kKxq`)EhSoITT^6S+{X@{W@b~Q`0xR zTZ}FBtJir^95OHRs=-9eX!S*dn1j*yz=rzuYa2d4`v6lD#%3|t(mYh|!q~hCOLSD$ zh80km4_`!fh{u}1p#1oc6GI9oaadC%3TKtbToLPVMpH6mPK8Q18RMBs@)!eAMh2y& zjA^n4TxGWuOhOUvL|41%^8+>GinekMHV;DXmT1!LPeR&BO(ZBJ*Xm2O3U{z83Yv4;84^(Pj#_%))U))!hP<-ED?C;Tvxy5Q@n>6L+YUZl2a4 z7-3GSs=G*Oa2_3v;GOHFs&vY*A?)uCBI(hb*~37 zw9WdwUS}%LZ9zL1v|%keFt}D`eO@0k#qnWb|5=VH=9}T#n|WS;Gu2QTirs&O3K66> z*5~y;(;gD!{yBa;{wV3MCOLk8V9M_ssE?^>cKuGkFeda`FuLxb^(()ZfQT%-zGFTC z6|25RdQ5v*kSH&UneMmg_frE?_Lt2t&-6PseO`Ant>qUvTmNkDh)ti@drbK~i{e_jvg^O`AsJZ{$G`SS?qq6q7A zUR=-J079u&B>&C&OfN!L7wV&pjofz7LbZV-)zA0OS7(?w_AOcz>|( jN>hDnm^=w(hOLqNSFLJIZ4;4A_bycuAF?1(yZ*lctvNYD literal 0 HcmV?d00001 diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index 53582a6..c32f752 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -109,14 +109,11 @@ const rpc = BrowserView.defineRPC({ ...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 }) => { + "window.beginResize": ({ edge }: { edge: string }) => { try { - const { beginResizeDrag, edgeStringToGdk } = require("./gtk-window.ts"); - const gdkEdge = edgeStringToGdk(edge); - if (gdkEdge === null) return { ok: false, error: `Unknown edge: ${edge}` }; - console.log(`[resize] edge=${edge} gdkEdge=${gdkEdge} btn=${button} rootX=${rootX} rootY=${rootY} ptr=${(mainWindow as any).ptr}`); - const ok = beginResizeDrag((mainWindow as any).ptr, gdkEdge, button, rootX, rootY); - console.log(`[resize] result: ${ok}`); + // Uses native C library — stored mouse state from real GTK event + const { startNativeResize } = require("./native-resize.ts"); + const ok = startNativeResize(edge); return { ok }; } catch (err) { console.error("[window.beginResize]", err); return { ok: false }; } }, @@ -240,11 +237,12 @@ mainWindow = new BrowserWindow({ }, }); -// GTK window setup — only log resizable status, skip all FFI modifications -// (forceSmallMinSize and wrapWebViewInScrolledWindow caused crashes) +// Native resize via C shared library (libagor-resize.so) +// Owns GTK signal connections in C — no JSCallback boundary crossing try { - console.log("[gtk] Window ptr:", (mainWindow as any).ptr); -} catch (e) { console.error("[gtk] ptr access failed:", e); } + const { initNativeResize } = require("./native-resize.ts"); + initNativeResize((mainWindow as any).ptr, 8); +} catch (e) { console.error("[native-resize] init failed:", e); } // Prevent GTK's false Ctrl+click detection from closing the window on initial load. // WebKitGTK reports stale modifier state (0x14 = Ctrl+Alt) after SIGTERM of previous instance, diff --git a/ui-electrobun/src/bun/native-resize.ts b/ui-electrobun/src/bun/native-resize.ts new file mode 100644 index 0000000..40cb9ec --- /dev/null +++ b/ui-electrobun/src/bun/native-resize.ts @@ -0,0 +1,94 @@ +/** + * Native resize via libagor-resize.so — C shared library that owns + * GTK signal connections and calls begin_resize_drag with real event data. + * + * The C library: + * 1. Connects button-press-event on GtkWindow + WebView children (in C) + * 2. Stores mouseButton, xroot, yroot, dragTime from real GTK events + * 3. Exports agor_resize_start(edge) that Bun calls via FFI + * + * This avoids JSCallback boundary crossing (which crashes in Bun). + */ + +import { dlopen, FFIType } from "bun:ffi"; +import { join } from "path"; +import { existsSync } from "fs"; + +let lib: ReturnType | null = null; + +function loadLib(): ReturnType | null { + if (lib) return lib; + + // Search paths for the .so + const candidates = [ + join(import.meta.dir, "../../agor-pty/native/libagor-resize.so"), + join(import.meta.dir, "../../../agor-pty/native/libagor-resize.so"), + "/home/hibryda/code/ai/agent-orchestrator/agor-pty/native/libagor-resize.so", + ]; + + const soPath = candidates.find(p => existsSync(p)); + if (!soPath) { + console.error("[native-resize] libagor-resize.so not found. Searched:", candidates); + return null; + } + + try { + lib = dlopen(soPath, { + agor_resize_init: { + args: [FFIType.ptr, FFIType.i32], + returns: FFIType.void, + }, + agor_resize_start: { + args: [FFIType.i32], + returns: FFIType.i32, + }, + }); + console.log("[native-resize] Loaded:", soPath); + return lib; + } catch (err) { + console.error("[native-resize] Failed to load:", err); + return null; + } +} + +/** + * Initialize native resize handler. Call once after window creation. + */ +export function initNativeResize(windowPtr: number | bigint, borderPx: number = 8): boolean { + const l = loadLib(); + if (!l) return false; + try { + l.symbols.agor_resize_init(windowPtr as any, borderPx); + return true; + } catch (err) { + console.error("[native-resize] init failed:", err); + return false; + } +} + +// GdkWindowEdge mapping +const EDGE_MAP: Record = { + n: 1, s: 6, e: 4, w: 3, + ne: 2, nw: 0, se: 7, sw: 5, +}; + +/** + * Start a resize drag. Call from RPC when JS detects mousedown in border zone. + * The C library uses stored mouse state from the real GTK button-press event. + */ +export function startNativeResize(edge: string): boolean { + const l = loadLib(); + if (!l) return false; + const gdkEdge = EDGE_MAP[edge]; + if (gdkEdge === undefined) { + console.error("[native-resize] Unknown edge:", edge); + return false; + } + try { + const ok = l.symbols.agor_resize_start(gdkEdge); + return ok === 1; + } catch (err) { + console.error("[native-resize] start failed:", err); + return false; + } +} diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index 45924d2..98aa2a1 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -104,55 +104,14 @@ e.preventDefault(); } - // ── Window resize — CSS handles capture mouse, X11 FFI resizes ── - 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', - }; - + // ── Window resize — JS edge detection + native C begin_resize_drag ── + // The Wails pattern: JS detects the edge, C library has real GTK event data. function onResizeStart(e: MouseEvent, edge: string) { e.preventDefault(); e.stopPropagation(); - resizeEdge = edge; - resizeStartX = e.screenX; - resizeStartY = e.screenY; - // Capture frame synchronously - resizeFrame = { - x: window.screenX, y: window.screenY, - width: window.outerWidth, height: window.outerHeight, - }; - document.body.style.cursor = CURSOR_MAP[edge] || 'default'; - document.body.style.userSelect = 'none'; - } - - function onResizeMove(e: MouseEvent) { - if (!resizeEdge) return; - e.preventDefault(); - 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; } - // Use Electrobun's setPosition + setSize (simpler than X11 FFI) - appRpc.request['window.setFrame']({ - 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(); + // Tell the native C library to start a resize drag + // It uses stored mouseButton/xroot/yroot from the real GTK button-press event + appRpc.request['window.beginResize']({ edge }).catch(() => {}); } // ── Window frame persistence (debounced 500ms) ────────────── @@ -253,9 +212,7 @@ setAgentToastFn(showToast); setupErrorBoundary(); - // JS resize needs document-level listeners - document.addEventListener('mousemove', onResizeMove); - document.addEventListener('mouseup', onResizeEnd); + // Resize handled by native C library — no JS mousemove/mouseup needed // Blink + session timers — MUST be in onMount, NOT $effect // $effect interacts with reactive graph and causes cycles @@ -351,8 +308,7 @@ clearInterval(sessionId); document.removeEventListener("keydown", handleSearchShortcut); window.removeEventListener("palette-command", handlePaletteCommand); - document.removeEventListener('mousemove', onResizeMove); - document.removeEventListener('mouseup', onResizeEnd); + // Native resize — no JS listeners to clean }; });