Synthesize window resize for the frameless win7 skin (a documented stopgap)

The win7 skin is frameless so 7.css can draw the Aero caption, and on GTK that
means undecorated, which takes the window manager's resize borders and corner
grips with it. All three win7 windows were completely unresizable.

electrobun 1.18.1 offers nothing to re-enable, verified against the shipped
binary rather than assumed: libNativeWrapper exports startWindowMove/
stopWindowMove with no resize counterpart, imports gtk_window_begin_move_drag
but not gtk_window_begin_resize_drag, dragRegions.ts implements moving only,
and BrowserWindow has neither setResizable nor min-size options. But the
window is still programmatically resizable, so only the input affordance is
missing: eight handles overlay the frame and drag it through a new
windowGetFrame/windowSetFrame pair on the shared windowControlHandlers factory,
which gives all three window kinds resize with no per-window wiring.

Three things that would have made this quietly wrong:

- dragRegions.ts starts a window MOVE from a mousedown listener on document,
  never checks defaultPrevented, and is not suppressed by a pointerdown
  handler either (the compatibility mousedown still fires). Every handle
  carries app-region-no-drag, which short-circuits its check first.
- deltas come from screenX, not clientX: a north/west resize moves the window
  origin under a stationary pointer, so client coords feed that back into the
  next delta and the window runs away.
- the min-size clamp re-anchors the opposite edge; clamping width alone lets x
  keep tracking the pointer, walking the window off-screen while it looks
  frozen.

This is a stopgap and the point is that it is removable on sight. The frame
math is a dependency-free module so scripts/resize-math-probe.ts asserts all
eight edges without a window (verified it catches the unanchored-clamp bug:
x jumps to 5100 instead of parking at 460). scripts/check-electrobun-resize.ts
scans the installed electrobun for the primitives this is waiting on and prints
the removal checklist when they appear; it runs from postinstall so a version
bump surfaces it, never fails an install, and is silent otherwise. Both trigger
paths were simulated. The component header carries the full checklist; docs
cover the platform consequence and the dragRegions traps.

Harness diffs exactly 0 in both themes; hover and menu probes still clean.
Not yet exercised with a real pointer — that needs a live window.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:16:48 +02:00
co-authored by Claude
parent 5ed029a2b8
commit 4605a481f1
17 changed files with 673 additions and 4 deletions
+12
View File
@@ -616,6 +616,18 @@ html.win7 body {
border-radius: 0;
}
/* STOPGAP — the synthesized resize handles (window-resize/window-resize.ts,
which carries the removal checklist). A maximized window has no edges to
grab, so the overlay goes away entirely while it is maximized, and comes
back on restore.
Read off the title bar's own `is-maximized` with :has(), exactly like the
corner-squaring rule above — that is what keeps this component free of a
second `windowState` subscription (RpcService's push registries have no
off(), so every subscription is permanent). */
.win7 .window.w7-frame:has(> .title-bar.is-maximized) .w7-resize {
display: none;
}
/* Hovering the close button paints 7.css's red glow ::before
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
clipped by the parent's border or radius — the caption buttons sit flush
+5
View File
@@ -63,6 +63,11 @@
<app-overwrite-dialog [request]="req" />
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-context-menu />
<app-toasts />
</div>
+2
View File
@@ -23,6 +23,7 @@ import { TitleBar } from './title-bar/title-bar';
import { ToastService } from '@core/toast.service';
import { Toasts } from './toasts/toasts';
import { TransferBar } from './transfer-bar/transfer-bar';
import { WindowResize } from './window-resize/window-resize';
import { TransfersService } from '@core/transfers.service';
import { UpdaterService } from '@core/updater.service';
@@ -41,6 +42,7 @@ import { UpdaterService } from '@core/updater.service';
TransferBar,
TitleBar,
ContextMenu,
WindowResize,
],
templateUrl: './app.html',
host: {
@@ -381,5 +381,10 @@
<app-confirm-dialog [request]="req" />
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-toasts />
</div>
@@ -12,6 +12,7 @@ import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
import { WindowResize } from '../window-resize/window-resize';
interface Draft {
id: string;
@@ -74,7 +75,7 @@ function fromDraft(d: Draft): SavedConnection {
*/
@Component({
selector: 'app-root',
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar],
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar, WindowResize],
templateUrl: './connection-manager.html',
host: {
'(window:keydown.escape)': 'onEscape()',
@@ -203,5 +203,10 @@
}
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-toasts />
</div>
@@ -18,6 +18,7 @@ import { ShortcutsService } from '@core/shortcuts.service';
import { ToastService } from '@core/toast.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
import { WindowResize } from '../window-resize/window-resize';
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
const DRAFT_DEBOUNCE_MS = 400;
@@ -32,7 +33,7 @@ const DRAFT_DEBOUNCE_MS = 400;
*/
@Component({
selector: 'app-root',
imports: [NgIcon, Toasts, TitleBar],
imports: [NgIcon, Toasts, TitleBar, WindowResize],
templateUrl: './editor-window.html',
host: {
'(window:keydown)': 'onKeydown($event)',
@@ -0,0 +1,85 @@
/**
* Frame arithmetic for the synthesized window resize. Deliberately a separate,
* DEPENDENCY-FREE module: `scripts/resize-math-probe.ts` imports it directly to
* assert all eight edges without booting Angular or opening a window, which is
* the only automated coverage this feature can have (pointer drags cannot be
* synthesized headlessly on this machine).
*
* Part of the resize stopgap — see the removal checklist in window-resize.ts.
*/
export interface Frame {
x: number;
y: number;
width: number;
height: number;
}
/** Compass point of the grabbed edge or corner. */
export type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
/** Every handle, in DOM order: edges first so the corners overlay them. */
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
/**
* Floor for the synthesized resize. electrobun has no min-size window option,
* so this is enforced here or not at all. Chosen to keep both panels usable
* with their toolbars intact; the interface scale can still make the CONTENT
* too big for it, which is a separate (pre-existing) problem.
*/
export const MIN_WIDTH = 640;
export const MIN_HEIGHT = 420;
/**
* The frame a drag should produce.
*
* `dx`/`dy` are SCREEN-space deltas from where the drag began (see the note on
* screenX in window-resize.ts). North and west edges move the window's origin
* as well as its size — that is the whole reason this returns a full frame
* rather than a size, and why `setSize` alone would be wrong.
*
* The clamp holds the ANCHORED edge still: dragging the west edge east past the
* minimum must park `x` where the window is exactly MIN_WIDTH wide, not let it
* keep travelling while the width sits pinned — that would walk the window
* across the screen while it appears frozen.
*
* Results are rounded: fractional frames make the native setter jitter by a
* pixel between ticks.
*/
export function resizeFrame(
edge: ResizeEdge,
start: Frame,
dx: number,
dy: number,
min: { width: number; height: number } = { width: MIN_WIDTH, height: MIN_HEIGHT },
): Frame {
let { x, y, width, height } = start;
if (edge.includes('e')) width = start.width + dx;
if (edge.includes('w')) {
width = start.width - dx;
x = start.x + dx;
}
if (edge.includes('s')) height = start.height + dy;
if (edge.includes('n')) {
height = start.height - dy;
y = start.y + dy;
}
if (width < min.width) {
// west drag: the east edge is the anchor, so x is derived from it
if (edge.includes('w')) x = start.x + start.width - min.width;
width = min.width;
}
if (height < min.height) {
if (edge.includes('n')) y = start.y + start.height - min.height;
height = min.height;
}
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
};
}
@@ -0,0 +1,191 @@
import { Component, inject } from '@angular/core';
import { RpcService } from '@core/rpc.service';
import { type Frame, RESIZE_EDGES, type ResizeEdge, resizeFrame } from './resize-math';
/* ============================================================================
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
* ============================================================================
*
* WHY IT EXISTS
*
* The win7 skin is frameless: `chromeOptions()` (src/bun/windows.ts) passes
* `titleBarStyle: "hidden"` so 7.css can draw the Aero caption itself, and on
* Linux the GTK wrapper honours that with `gtk_window_set_decorated(false)`.
* Undecorating the window also removes the window manager's resize borders and
* corner grips — so all three win7 windows became completely unresizable, on
* every platform. (The modern skin keeps the native frame and is unaffected;
* it must never mount this component.)
*
* Electrobun 1.18.1 offers nothing to re-enable. Verified against the SHIPPED
* binary rather than assumed:
*
* - libNativeWrapper.so exports `startWindowMove` / `stopWindowMove` and no
* resize counterpart whatsoever. Its only `resize*` exports are
* `resizeWebview` / `resizeAutoSizingWebviewsInWindow`, internal webview
* plumbing unrelated to the window frame.
* - It imports `gtk_window_begin_move_drag` but NOT
* `gtk_window_begin_resize_drag`, and not `gtk_window_set_resizable`.
* - `dist/api/bun/preload/dragRegions.ts` implements moving only: there is no
* `app-region: resize`, no per-edge variant.
* - `BrowserWindow` has no `setResizable`, and its constructor options carry
* no `resizable` / `minWidth` / `minHeight`.
*
* The window is still PROGRAMMATICALLY resizable — nothing ever calls
* `gtk_window_set_resizable(false)`, and `setFrame`/`getFrame` work. So only
* the input affordance is missing, and that is exactly what this synthesizes:
* eight handles → pointer drag → `windowSetFrame` RPC → `BrowserWindow.setFrame`.
*
* WHAT IT COSTS (i.e. why it should die as soon as it can)
*
* Every drag tick is an RPC hop plus a synchronous FFI call, where a real WM
* grab is a compositor-side operation — so it is jerkier than a native resize
* and always will be. Minimum size is enforced in JS because electrobun has no
* min-size option. And the handles occupy real estate inside the window that a
* native frame would have provided outside it.
*
* ────────────────────────────────────────────────────────────────────────────
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
*
* 1. this directory (window-resize.ts + resize-math.ts)
* 2. its three mount points: `<app-window-resize />` and the `WindowResize`
* entry in `imports:` for skins/7like/{app, connection-manager/
* connection-manager, editor-window/editor-window}
* 3. the `.w7-resize` rule in skins/7like/7like.css (@layer skinfix)
* 4. `windowGetFrame` / `windowSetFrame` in src/shared/rpc.ts
* 5. their handlers in `windowControlHandlers` (src/bun/rpc-handlers.ts)
* 6. scripts/check-electrobun-resize.ts and scripts/resize-math-probe.ts
* 7. the check-script call in package.json's `postinstall`
* 8. the resize paragraphs in docs/{gotchas,platform-notes,architecture}.md
*
* Then adopt whatever upstream provides — most likely a `no-drag`-style CSS
* class or a `startWindowResize(edge)` preload call, which needs handles for
* hit-testing but none of the frame arithmetic below.
*
* HOW WE FIND OUT: `scripts/check-electrobun-resize.ts` inspects the installed
* electrobun for exactly those symbols and prints this checklist when they
* appear. It runs from `postinstall`, so an electrobun bump surfaces it without
* anyone remembering to look.
* ============================================================================
*
* IMPLEMENTATION NOTES
*
* - `electrobun-webkit-app-region-no-drag` on every handle is LOAD-BEARING, not
* decoration. `dragRegions.ts` starts a window MOVE from a `mousedown`
* listener on `document` in the bubble phase; it never checks
* `defaultPrevented`, so `preventDefault()` does not stop it, and a
* `pointerdown` handler does not either (the compatibility `mousedown` still
* fires afterwards, and stopping a POINTER event does nothing to a MOUSE
* event). `isAppRegionDrag` tests for the no-drag class first and
* short-circuits, so the class is the only reliable suppressor. Without it
* every resize drag would also drag the window.
* - Deltas come from `screenX/screenY`, never `clientX/clientY`. Resizing from
* the north or west edge moves the window's origin, which moves the webview's
* client origin under a stationary pointer — client coords would feed that
* movement back into the next delta and the window would run away.
* - The frame is read once per drag, asynchronously, but the pointer origin is
* recorded synchronously in the same handler: a move that arrives before the
* frame does is simply ignored, and no delta is measured from a stale origin.
* - At most one setFrame is in flight, with the newest target coalesced behind
* it. Firing one RPC per pointermove would queue faster than it drains and
* the window would keep resizing after the mouse stopped.
* - Handles are hidden when maximized by CSS alone (7like.css reads the title
* bar's own `is-maximized` through `:has()`), so this component never
* subscribes to `windowState`. That matters: RpcService's push registries
* have no `off()`, so every subscription is permanent.
*/
/** Edge → the Tailwind box that places and cues it. Edge thickness is 0.375rem
* = 6px at 1×, matching the gutter 7.css's `.window-body { margin: 0 6px 6px }`
* leaves around the content, so left/right/bottom handles sit on frame chrome
* instead of stealing clicks from the panels. Rem, so the grab area scales with
* the interface scale like everything else. Corners are listed last: same
* z-index, later in the DOM, so they win the overlap. */
const HANDLE_BOX: Record<ResizeEdge, string> = {
n: 'top-0 right-0 left-0 h-1.5 cursor-ns-resize',
s: 'right-0 bottom-0 left-0 h-1.5 cursor-ns-resize',
w: 'top-0 bottom-0 left-0 w-1.5 cursor-ew-resize',
e: 'top-0 right-0 bottom-0 w-1.5 cursor-ew-resize',
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
se: 'right-0 bottom-0 size-3 cursor-nwse-resize',
};
@Component({
selector: 'app-window-resize',
template: `
@for (edge of edges; track edge) {
<div
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ box[edge] }}"
(pointerdown)="onPointerDown($event, edge)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerEnd()"
(pointercancel)="onPointerEnd()"
></div>
}
`,
// The overlay spans the frame but is transparent to the pointer; only the
// handles inside it take hits. `w7-resize` is what the maximized rule hides.
host: { class: 'w7-resize pointer-events-none absolute inset-0 z-10' },
})
export class WindowResize {
protected readonly edges = RESIZE_EDGES;
protected readonly box = HANDLE_BOX;
private readonly rpc = inject(RpcService);
/** Null except during a drag. Set only once the frame read resolves. */
private drag: { edge: ResizeEdge; screenX: number; screenY: number; frame: Frame } | null = null;
/** Newest target frame not yet sent, if a send is already in flight. */
private pending: Frame | null = null;
private sending = false;
onPointerDown(event: PointerEvent, edge: ResizeEdge): void {
if (!this.rpc.available || event.button !== 0) return;
(event.target as HTMLElement).setPointerCapture(event.pointerId);
// captured synchronously — the frame read below is a round trip
const screenX = event.screenX;
const screenY = event.screenY;
void this.rpc.request.windowGetFrame({}).then((res) => {
if (!res.ok) return;
this.drag = {
edge,
screenX,
screenY,
frame: { x: res.x, y: res.y, width: res.width, height: res.height },
};
});
}
onPointerMove(event: PointerEvent): void {
const drag = this.drag;
if (!drag) return; // moved before the frame arrived, or not dragging
this.pending = resizeFrame(
drag.edge,
drag.frame,
event.screenX - drag.screenX,
event.screenY - drag.screenY,
);
void this.flush();
}
onPointerEnd(): void {
this.drag = null; // capture releases itself on pointerup/cancel
}
/** Drain `pending`, one request at a time. Re-entrant calls return
* immediately; the running loop picks up whatever they left behind. */
private async flush(): Promise<void> {
if (this.sending) return;
this.sending = true;
try {
while (this.pending) {
const frame = this.pending;
this.pending = null;
await this.rpc.request.windowSetFrame(frame);
}
} finally {
this.sending = false;
}
}
}