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
This commit is contained in:
Hibryda 2026-03-25 02:23:24 +01:00
parent 48d32f6f28
commit 9da9d96ebd
4 changed files with 149 additions and 72 deletions

View file

@ -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<typeof dlopen> | 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<string, GdkEdge> = {
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;
}

View file

@ -108,6 +108,24 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
...providerHandlers, ...providerHandlers,
...miscHandlers, ...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 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.minimize": () => { try { mainWindow.minimize(); return { ok: true }; } catch (err) { console.error("[window.minimize]", err); return { ok: false }; } },
"window.maximize": () => { "window.maximize": () => {

View file

@ -91,80 +91,30 @@
appRpc.request["window.minimize"]({}).catch(console.error); appRpc.request["window.minimize"]({}).catch(console.error);
} }
// ── Window drag (on sidebar/right-bar empty space) ────────── // ── Window drag — delegates to GTK window manager ───────────
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let winStartX = 0;
let winStartY = 0;
function onDragStart(e: MouseEvent) { function onDragStart(e: MouseEvent) {
// Only start drag from the sidebars themselves, not child buttons
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.closest('button')) return; if (target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.closest('button')) return;
isDragging = true; // Delegate to GTK — the WM handles everything (smooth, zero CPU)
dragStartX = e.screenX; appRpc.request['window.beginMove']({
dragStartY = e.screenY; button: e.button + 1, // DOM: 0=left, GTK: 1=left
winStartX = cachedFrame.x; rootX: e.screenX,
winStartY = cachedFrame.y; rootY: e.screenY,
}).catch(() => {});
e.preventDefault(); 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) { function onResizeStart(e: MouseEvent, edge: string) {
resizeEdge = edge; appRpc.request['window.beginResize']({
resizeStartX = e.screenX; edge,
resizeStartY = e.screenY; button: e.button + 1,
resizeFrame = { ...cachedFrame }; rootX: e.screenX,
isResizing = true; rootY: e.screenY,
resizeReady = true; }).catch(() => {});
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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) ────────────── // ── Window frame persistence (debounced 500ms) ──────────────
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null; let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
@ -264,14 +214,7 @@
setAgentToastFn(showToast); setAgentToastFn(showToast);
setupErrorBoundary(); setupErrorBoundary();
// Seed cached frame from actual window position // No global mousemove/mouseup needed — GTK WM handles drag/resize natively
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);
// Blink + session timers — MUST be in onMount, NOT $effect // Blink + session timers — MUST be in onMount, NOT $effect
// $effect interacts with reactive graph and causes cycles // $effect interacts with reactive graph and causes cycles

View file

@ -343,6 +343,17 @@ export type PtyRPCRequests = {
response: { ok: boolean }; 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 ──────────────────────────────────────────────────────── // ── Keybindings RPC ────────────────────────────────────────────────────────
/** Return all persisted custom keybindings (overrides only). */ /** Return all persisted custom keybindings (overrides only). */