diff --git a/docs/architecture.md b/docs/architecture.md index 41e03c0..1e491eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -483,12 +483,16 @@ lights to reserve `pl-24` for. **Window resize (STOPGAP)**: undecorating the window also removes the WM's resize borders, and electrobun 1.18.1 has no native resize gesture — so -`skins/7like/window-resize/` synthesizes one: eight handles overlaying the frame -drag it through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`. +`skins/7like/window-resize/` synthesizes one: a bottom-right grip drags the +frame through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`. Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule -(no second `windowState` subscription), and every handle carries +(no second `windowState` subscription), and the grip carries `electrobun-webkit-app-region-no-drag` or the drag region would move the window -instead. The frame math is a dependency-free module so +instead. **One corner, not eight handles**: the handles are invisible and paint +above the frame, so a north strip or top corner overlays the caption buttons and +steals their `:hover`, which reads as the Aero glow flickering on those buttons. +The bottom-right corner is the only one that overlaps nothing (it sits in the +6px gutter `.window-body`'s margin leaves). The frame math is a dependency-free module so `scripts/resize-math-probe.ts` can assert all eight edges without a window. **This is meant to be deleted**: the component header carries the removal checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`) diff --git a/docs/gotchas.md b/docs/gotchas.md index a65de33..4269b41 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -269,11 +269,21 @@ touching the adjacent code. - **A frameless window cannot be resized, and `preventDefault` will not save you from the drag region.** Undecorating the GTK window (what `titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and - electrobun exposes no resize gesture — hence the synthesized handles in + electrobun exposes no resize gesture — hence the synthesized grip in `ui/src/app/skins/7like/window-resize/` (a STOPGAP; that file carries the removal checklist, and `scripts/check-electrobun-resize.ts` fires from - `postinstall` when upstream makes it deletable). Three traps live in that - code, all verified against `dist/api/bun/preload/dragRegions.ts`: + `postinstall` when upstream makes it deletable). + **An invisible resize handle over a control steals that control's hover.** A + full eight-handle set shipped briefly and was reverted: the north strip ran + the whole width across the caption buttons and the north-east square sat on + the close button's corner, so crossing one dropped the button's `:hover` + mid-glow and looked like a rendering artifact in 7.css — nothing in the CSS + was wrong. Only the bottom-right corner overlaps nothing (it lands in the 6px + gutter `.window-body { margin: 0 6px 6px }` leaves). Any new handle must be + checked against what sits under it; south/east/west are innocent, north and + both top corners are not. + Three more traps live in that code, all verified against + `dist/api/bun/preload/dragRegions.ts`: - It starts a window **move** from a `mousedown` listener on `document`, in the **bubble** phase. It never reads `defaultPrevented`, so `event.preventDefault()` does **nothing** to stop it. diff --git a/docs/platform-notes.md b/docs/platform-notes.md index af839ca..415e2ae 100644 --- a/docs/platform-notes.md +++ b/docs/platform-notes.md @@ -44,8 +44,10 @@ counterpart, does not link `gtk_window_begin_resize_drag`, and `BrowserWindow` has neither `setResizable` nor min-size constructor options. The window is still *programmatically* resizable (`setFrame`/`getFrame` work, and `gtk_window_set_resizable(false)` is never called), so the app **synthesizes the -input affordance**: eight handles in the webview drag the native frame over RPC -(`ui/src/app/skins/7like/window-resize/`). +input affordance**: a bottom-right grip in the webview drags the native frame +over RPC (`ui/src/app/skins/7like/window-resize/`). Only that one corner — an +invisible handle anywhere else overlays a control and steals its hover, which is +what a full edge set did to the caption buttons. That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the diff --git a/scripts/resize-math-probe.ts b/scripts/resize-math-probe.ts index d4a747d..156a4c8 100644 --- a/scripts/resize-math-probe.ts +++ b/scripts/resize-math-probe.ts @@ -14,6 +14,11 @@ // The failure this guards against is not "the window does not resize" (obvious // on sight) but "dragging the west edge inward past the minimum walks the whole // window across the screen" — correct-looking until you hit the limit. +// +// This covers MORE than the UI currently mounts, deliberately: only the +// bottom-right grip is wired up (an invisible handle over a caption button +// steals its hover — see the component), but the math stays general so that +// restoring a side later is one line against already-proven arithmetic. import { MIN_HEIGHT, MIN_WIDTH, diff --git a/ui/src/app/skins/7like/window-resize/resize-math.ts b/ui/src/app/skins/7like/window-resize/resize-math.ts index 0dde3c6..f5eb367 100644 --- a/ui/src/app/skins/7like/window-resize/resize-math.ts +++ b/ui/src/app/skins/7like/window-resize/resize-math.ts @@ -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']; /** diff --git a/ui/src/app/skins/7like/window-resize/window-resize.ts b/ui/src/app/skins/7like/window-resize/window-resize.ts index 63a62d8..7ebcb4e 100644 --- a/ui/src/app/skins/7like/window-resize/window-resize.ts +++ b/ui/src/app/skins/7like/window-resize/window-resize.ts @@ -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 = { - 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) { -
- } +
`, // 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,