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:
parent
48d32f6f28
commit
9da9d96ebd
4 changed files with 149 additions and 72 deletions
105
ui-electrobun/src/bun/gtk-window.ts
Normal file
105
ui-electrobun/src/bun/gtk-window.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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": () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue