feat(electrobun): custom window chrome — no title bar, sidebar drag, edge resize

- titleBarStyle: "hidden" removes native GTK decoration
- Sidebar + right-bar: mousedown starts window drag (skips buttons)
- 8 resize handles (N/S/E/W + 4 corners) with 4px hot zones
- window.setFrame RPC for atomic position+size updates
- Min window size: 600x400
- Cursor feedback: grab on sidebars, directional resize on edges
- Frame persisted to SQLite on drag/resize end (debounced)
This commit is contained in:
Hibryda 2026-03-25 01:52:17 +01:00
parent 1de6c93e01
commit 31338ad949
2 changed files with 106 additions and 5 deletions

View file

@ -120,6 +120,9 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
"window.close": () => { try { mainWindow.close(); return { ok: true }; } catch (err) { console.error("[window.close]", err); return { ok: false }; } },
"window.getFrame": () => { try { return mainWindow.getFrame(); } catch { return { x: 0, y: 0, width: 1400, height: 900 }; } },
"window.setPosition": ({ x, y }: { x: number; y: number }) => { try { mainWindow.setPosition(x, y); return { ok: true }; } catch { return { ok: false }; } },
"window.setFrame": ({ x, y, width, height }: { x: number; y: number; width: number; height: number }) => {
try { mainWindow.setFrame({ x, y, width, height }); return { ok: true }; } catch { return { ok: false }; }
},
},
messages: {},
},
@ -181,7 +184,7 @@ const savedHeight = Number(settingsDb.getSetting("win_height") ?? 900);
mainWindow = new BrowserWindow({
title: "Agent Orchestrator",
titleBarStyle: "default",
titleBarStyle: "hidden",
url,
rpc,
frame: {

View file

@ -91,6 +91,66 @@
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;
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;
appRpc.request['window.getFrame']({}).then(f => {
winStartX = f.x; winStartY = f.y;
}).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() { isDragging = false; saveWindowFrame(); }
// ── Window resize (edge handles) ───────────────────────────
let isResizing = false;
let resizeEdge = '';
let resizeStartX = 0;
let resizeStartY = 0;
let resizeFrame = { x: 0, y: 0, width: 0, height: 0 };
const MIN_W = 600;
const MIN_H = 400;
function onResizeStart(e: MouseEvent, edge: string) {
isResizing = true;
resizeEdge = edge;
resizeStartX = e.screenX;
resizeStartY = e.screenY;
appRpc.request['window.getFrame']({}).then(f => {
resizeFrame = { ...f };
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
}
function onResizeMove(e: MouseEvent) {
if (!isResizing) return;
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() { isResizing = false; saveWindowFrame(); }
// ── Window frame persistence (debounced 500ms) ──────────────
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
function saveWindowFrame() {
@ -189,6 +249,12 @@
setAgentToastFn(showToast);
setupErrorBoundary();
// 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
// $effect interacts with reactive graph and causes cycles
// Blink timer is in blink-store — start it here
@ -302,11 +368,29 @@
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>
<div class="app-shell" role="presentation">
<!-- Left sidebar icon rail -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
<!-- Left sidebar icon rail — draggable for window move -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation" onmousedown={onDragStart}>
<!-- AGOR vertical title -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="agor-title" aria-hidden="true">AGOR</div>
<!-- Group icons — numbered circles -->
@ -486,7 +570,8 @@
</main>
<!-- Right sidebar: window controls + notification bell -->
<aside class="right-bar" aria-label="Window controls and notifications">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<aside class="right-bar" aria-label="Window controls and notifications" onmousedown={onDragStart}>
<div
class="window-controls-vertical"
role="toolbar"
@ -572,6 +657,17 @@
height: 100vh;
}
/* ── Resize handles ─────────────────────────────────── */
.rz { position: fixed; z-index: 9999; }
.rz-n { top: 0; left: 4px; right: 4px; height: 4px; cursor: n-resize; }
.rz-s { bottom: 0; left: 4px; right: 4px; height: 4px; cursor: s-resize; }
.rz-e { right: 0; top: 4px; bottom: 4px; width: 4px; cursor: e-resize; }
.rz-w { left: 0; top: 4px; bottom: 4px; width: 4px; cursor: w-resize; }
.rz-ne { top: 0; right: 0; width: 8px; height: 8px; cursor: ne-resize; }
.rz-nw { top: 0; left: 0; width: 8px; height: 8px; cursor: nw-resize; }
.rz-se { bottom: 0; right: 0; width: 8px; height: 8px; cursor: se-resize; }
.rz-sw { bottom: 0; left: 0; width: 8px; height: 8px; cursor: sw-resize; }
.app-shell {
flex: 1;
min-height: 0;
@ -590,7 +686,9 @@
align-items: center;
padding: 0.375rem 0 0.5rem;
gap: 0.125rem;
cursor: grab;
}
.sidebar:active { cursor: grabbing; }
.agor-title {
writing-mode: vertical-rl;