Reduce the synthesized resize to the bottom-right grip only

The handles are invisible and paint above the frame, so the north strip ran the
full window width across the caption buttons and the north-east square sat on
the close button's own top-right corner. Crossing one dropped that button's
:hover mid-glow, which reads as the Aero glow flickering or sticking — an
artifact with nothing wrong in the CSS at all.

Only the bottom-right corner overlaps nothing: it lands in the 6px gutter that
.window-body's margin leaves below and right of the content. That is also where
Win7 put its own resize gripper, so one grip is the more faithful answer anyway.
Bumped to 1rem from 0.75rem since it is now the only way to resize.

resizeFrame stays general and the probe still exercises all eight edges: the
origin-moving cases are the subtle ones and are worth keeping proven, so
restoring a side later is one entry in the component against tested math. Both
the component and gotchas.md now spell out which edges are safe to restore
(south, east, west) and which are not (north and both top corners).

Harness diffs 0; math, hover and menu probes clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:28:49 +02:00
co-authored by Claude
parent 4605a481f1
commit 0a0c708140
6 changed files with 81 additions and 45 deletions
@@ -18,7 +18,14 @@ export interface Frame {
/** 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. */
/**
* Every edge the arithmetic supports — NOT what the UI mounts. The window
* currently offers only the bottom-right grip (`window-resize.ts` explains why
* the others were removed: an invisible handle over a caption button steals its
* hover). This stays complete because the origin-moving cases are the subtle
* ones and the probe is where they are proven; restoring a side is then one
* entry in the component, against already-tested math.
*/
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
/**
@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core';
import { RpcService } from '@core/rpc.service';
import { type Frame, RESIZE_EDGES, type ResizeEdge, resizeFrame } from './resize-math';
import { type Frame, type ResizeEdge, resizeFrame } from './resize-math';
/* ============================================================================
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
@@ -33,15 +33,19 @@ import { type Frame, RESIZE_EDGES, type ResizeEdge, resizeFrame } from './resize
* 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`.
* a bottom-right grip → pointer drag → `windowSetFrame` RPC →
* `BrowserWindow.setFrame`. One corner, not eight handles — see GRIP_BOX for
* why the others had to go.
*
* 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.
* min-size option. The grip occupies real estate INSIDE the window that a
* native frame would have provided outside it — which is also why there is only
* one of them: an invisible handle over a control steals that control's hover
* (see GRIP_BOX). A real WM resize border has neither problem.
*
* ────────────────────────────────────────────────────────────────────────────
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
@@ -94,53 +98,58 @@ import { type Frame, RESIZE_EDGES, type ResizeEdge, resizeFrame } from './resize
* 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',
};
/**
* ONE handle: the bottom-right grip, exactly where Win7 put its resize gripper.
*
* A full eight-handle set was tried and reverted. The handles are invisible and
* sit ON TOP of the frame (the overlay is above the title bar in paint order),
* so the north strip ran the full width across the caption buttons and the
* north-east square sat on the close button's own top-right corner. Crossing
* one stole `:hover` from the button underneath, and since 7.css cross-fades
* the caption glow, a stolen hover reads as the glow flickering or sticking —
* "weird artifacts on the buttons", with no visual bug anywhere in the CSS.
*
* The bottom-right corner is the one place that overlaps nothing: it lands in
* the 6px gutter that `.window-body { margin: 0 6px 6px }` leaves below and
* right of the content. `resizeFrame` stays general (and the probe still covers
* all eight edges) so restoring a side is one entry here — but any new handle
* must be checked against what it covers first. South, east and west are the
* innocent ones; north and both top corners are not.
*
* 1rem, not the 0.75rem the corners had: this is now the only way to resize the
* window, so the target earns the extra pixels. Rem, so it scales with the
* interface scale like everything else.
*/
const GRIP_EDGE: ResizeEdge = 'se';
const GRIP_BOX = 'right-0 bottom-0 size-4 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>
}
<div
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ gripBox }}"
(pointerdown)="onPointerDown($event)"
(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;
protected readonly gripBox = GRIP_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;
private drag: { 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 {
onPointerDown(event: PointerEvent): 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
@@ -149,7 +158,6 @@ export class WindowResize {
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 },
@@ -161,7 +169,7 @@ export class WindowResize {
const drag = this.drag;
if (!drag) return; // moved before the frame arrived, or not dragging
this.pending = resizeFrame(
drag.edge,
GRIP_EDGE,
drag.frame,
event.screenX - drag.screenX,
event.screenY - drag.screenY,