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:
@@ -481,6 +481,19 @@ start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME
|
|||||||
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
|
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
|
||||||
lights to reserve `pl-24` for.
|
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`.
|
||||||
|
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
|
||||||
|
(no second `windowState` subscription), and every handle 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
|
||||||
|
`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`)
|
||||||
|
prints it when upstream ships the primitive. See platform-notes.md.
|
||||||
|
|
||||||
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
|
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
|
||||||
`windowClose` / `windowIsMaximized` requests, built by
|
`windowClose` / `windowIsMaximized` requests, built by
|
||||||
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
|
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
|
||||||
|
|||||||
@@ -266,6 +266,31 @@ touching the adjacent code.
|
|||||||
pattern appears in the killing command's OWN argv. Break the literal with a
|
pattern appears in the killing command's OWN argv. Break the literal with a
|
||||||
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
|
||||||
has always worked.
|
has always worked.
|
||||||
|
- **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
|
||||||
|
`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`:
|
||||||
|
- 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.
|
||||||
|
- A `pointerdown` handler does not stop it either: the compatibility
|
||||||
|
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
|
||||||
|
event has no effect on a *mouse* event. This is the trap that looks fixed
|
||||||
|
in every test until the window jumps.
|
||||||
|
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
|
||||||
|
and short-circuits, so that class — on the handle itself — is the only
|
||||||
|
reliable suppressor. Without it, every resize drag also drags the window.
|
||||||
|
Also: deltas must come from `screenX/screenY`. North/west resizes move the
|
||||||
|
window's origin, which moves the webview's client origin under a stationary
|
||||||
|
pointer, so `clientX` feeds that movement back into the next delta and the
|
||||||
|
window runs away. And the minimum-size clamp must re-anchor the opposite
|
||||||
|
edge — clamping the width alone lets `x` keep tracking the pointer, walking
|
||||||
|
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
|
||||||
|
asserts exactly that, and was verified to catch it).
|
||||||
- **Prefer the headless probes over launching the app.** `bun run start` opens a
|
- **Prefer the headless probes over launching the app.** `bun run start` opens a
|
||||||
real window that takes focus, which interrupts whatever the user is doing;
|
real window that takes focus, which interrupts whatever the user is doing;
|
||||||
there is no Xvfb/xvfb-run on this machine and Wayland offers no
|
there is no Xvfb/xvfb-run on this machine and Wayland offers no
|
||||||
|
|||||||
+24
-1
@@ -8,7 +8,9 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
|||||||
|
|
||||||
| Concern | File | mac | win | linux |
|
| Concern | File | mac | win | linux |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
| Window chrome (modern skin) | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
||||||
|
| Window chrome (win7 skin) | `src/bun/windows.ts chromeOptions` | `hidden` (clears Titled → no traffic lights) | frameless | `gtk_window_set_decorated(false)` |
|
||||||
|
| Window **resize** | `ui/.../7like/window-resize/` | native frame resizes; win7 frameless uses the synthesized handles | as mac | as mac |
|
||||||
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
||||||
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||||
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||||
@@ -31,6 +33,27 @@ the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
|||||||
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||||
(`path.posix` throughout `fs/sftp.ts`).
|
(`path.posix` throughout `fs/sftp.ts`).
|
||||||
|
|
||||||
|
### Frameless windows lose the WM's resize borders (win7 skin, every platform)
|
||||||
|
|
||||||
|
`titleBarStyle: "hidden"` is what lets 7.css draw the Aero caption — on Linux the
|
||||||
|
GTK wrapper honours it with `gtk_window_set_decorated(false)`. Undecorating also
|
||||||
|
removes the window manager's resize borders and corner grips, and electrobun
|
||||||
|
1.18.1 offers **no** native resize gesture to put back: the shipped
|
||||||
|
`libNativeWrapper` exports `startWindowMove`/`stopWindowMove` with no resize
|
||||||
|
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/`).
|
||||||
|
|
||||||
|
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
|
||||||
|
installed electrobun for the missing primitives and prints the removal checklist
|
||||||
|
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
|
||||||
|
without anyone remembering to look. The modern skin keeps the native frame and
|
||||||
|
needs none of this.
|
||||||
|
|
||||||
## Memory footprint (expected, not a bug)
|
## Memory footprint (expected, not a bug)
|
||||||
|
|
||||||
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"build:ui": "bun run --cwd ui build",
|
"build:ui": "bun run --cwd ui build",
|
||||||
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
||||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
|
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
|
||||||
"postinstall": "bun scripts/gen-version.ts",
|
"postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts",
|
||||||
"release": "bun scripts/release.ts"
|
"release": "bun scripts/release.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Watches for electrobun growing a real window-resize gesture, so the
|
||||||
|
// synthesized one can be deleted the moment it becomes dead weight.
|
||||||
|
//
|
||||||
|
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS
|
||||||
|
//
|
||||||
|
// The win7 skin is frameless, which on GTK means undecorated, which means the
|
||||||
|
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
|
||||||
|
// native resize gesture at all, so the app synthesizes one: handles in the
|
||||||
|
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
|
||||||
|
// compositor-side grab and always will be — and the thing most likely to
|
||||||
|
// happen to a stopgap is that it outlives its reason and nobody notices.
|
||||||
|
//
|
||||||
|
// So the trigger is automated rather than a comment somebody has to re-read: an
|
||||||
|
// electrobun bump runs `postinstall`, which runs this, which prints the removal
|
||||||
|
// checklist the moment upstream ships the primitive we are waiting for.
|
||||||
|
//
|
||||||
|
// It NEVER fails the install (always exit 0) and is silent when there is
|
||||||
|
// nothing to say — a check that cries wolf gets muted, and a muted check is
|
||||||
|
// worse than none.
|
||||||
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** The version the synthesized resize was written against and verified on. If
|
||||||
|
* the installed version differs, this script says so once — that is the cue to
|
||||||
|
* re-check upstream's changelog and then bump this constant. */
|
||||||
|
const VALIDATED_VERSION = "1.18.1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
|
||||||
|
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
|
||||||
|
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
|
||||||
|
* twin. Any of these appearing is the signal.
|
||||||
|
*
|
||||||
|
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
|
||||||
|
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
|
||||||
|
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
|
||||||
|
* binary as plain text either way.
|
||||||
|
*/
|
||||||
|
const NATIVE_NEEDLES = [
|
||||||
|
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
|
||||||
|
"gtk_window_set_resizable",
|
||||||
|
"startWindowResize", // the obvious sibling of startWindowMove
|
||||||
|
"setWindowResizable",
|
||||||
|
"beginWindowResize",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Source-level signals in the shipped TS API. */
|
||||||
|
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
|
||||||
|
{
|
||||||
|
file: "dist/api/bun/core/BrowserWindow.ts",
|
||||||
|
needle: "setResizable",
|
||||||
|
means: "BrowserWindow gained a resizability setter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "dist/api/bun/core/BrowserWindow.ts",
|
||||||
|
needle: "minWidth",
|
||||||
|
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "dist/api/bun/preload/dragRegions.ts",
|
||||||
|
needle: "resize",
|
||||||
|
means: "the drag-region preload now knows about resizing",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHECKLIST = `
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ electrobun may now support window resizing natively. │
|
||||||
|
│ The synthesized resize is a STOPGAP and should be deleted. │
|
||||||
|
│ Full checklist: │
|
||||||
|
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘`;
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
|
||||||
|
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
|
||||||
|
|
||||||
|
const hits: string[] = [];
|
||||||
|
|
||||||
|
// --- the shipped native library, whichever platform's is installed ---
|
||||||
|
for (const entry of readdirSync(pkgDir)) {
|
||||||
|
if (!entry.startsWith("dist-")) continue;
|
||||||
|
const dir = path.join(pkgDir, entry);
|
||||||
|
for (const file of readdirSync(dir)) {
|
||||||
|
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
|
||||||
|
// latin1: we are searching for ASCII symbol names inside a binary, and
|
||||||
|
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
|
||||||
|
// sequences), which could hide a needle sitting next to one.
|
||||||
|
const text = readFileSync(path.join(dir, file), "latin1");
|
||||||
|
for (const needle of NATIVE_NEEDLES) {
|
||||||
|
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the shipped TypeScript API ---
|
||||||
|
for (const { file, needle, means } of SOURCE_NEEDLES) {
|
||||||
|
const full = path.join(pkgDir, file);
|
||||||
|
if (!existsSync(full)) continue;
|
||||||
|
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = installedVersion(pkgDir);
|
||||||
|
|
||||||
|
if (hits.length) {
|
||||||
|
console.log(CHECKLIST);
|
||||||
|
console.log(` electrobun ${version ?? "?"} — what changed:`);
|
||||||
|
for (const hit of hits) console.log(` · ${hit}`);
|
||||||
|
console.log("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No resize support. Stay quiet unless the version moved out from under the
|
||||||
|
// hack, in which case one line is the reminder to look.
|
||||||
|
if (version && version !== VALIDATED_VERSION) {
|
||||||
|
console.log(
|
||||||
|
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
|
||||||
|
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
|
||||||
|
`scripts/check-electrobun-resize.ts to silence this.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installedVersion(pkgDir: string): string | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broken check must never break an install: swallow everything, exit 0.
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
// Deliberately quiet — this is advisory. Surface it only when run by hand.
|
||||||
|
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Programmatic test for the synthesized window resize's frame arithmetic.
|
||||||
|
//
|
||||||
|
// bun scripts/resize-math-probe.ts
|
||||||
|
//
|
||||||
|
// Why this exists: the win7 skin resizes itself by dragging handles and moving
|
||||||
|
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
|
||||||
|
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
|
||||||
|
// headlessly on this machine, so the interaction itself needs a human; but the
|
||||||
|
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
|
||||||
|
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
|
||||||
|
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
|
||||||
|
// which is deliberately dependency-free so this script can drive it directly.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
import {
|
||||||
|
MIN_HEIGHT,
|
||||||
|
MIN_WIDTH,
|
||||||
|
RESIZE_EDGES,
|
||||||
|
type Frame,
|
||||||
|
type ResizeEdge,
|
||||||
|
resizeFrame,
|
||||||
|
} from "../ui/src/app/skins/7like/window-resize/resize-math";
|
||||||
|
|
||||||
|
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
|
||||||
|
|
||||||
|
interface Case {
|
||||||
|
name: string;
|
||||||
|
edge: ResizeEdge;
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
want: Frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every expectation is written out in full rather than computed, so a bug in
|
||||||
|
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
|
||||||
|
const CASES: Case[] = [
|
||||||
|
// --- edges: each moves only what it owns ---
|
||||||
|
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
|
||||||
|
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
|
||||||
|
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
|
||||||
|
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
|
||||||
|
// --- corners: two axes at once ---
|
||||||
|
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
|
||||||
|
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
|
||||||
|
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
|
||||||
|
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
|
||||||
|
// --- the anchored edge must not drift once clamped ---
|
||||||
|
// west dragged far east: width parks at MIN, and x parks where the EAST edge
|
||||||
|
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
|
||||||
|
// would slide away while appearing frozen.
|
||||||
|
{
|
||||||
|
name: "west clamps, east edge holds",
|
||||||
|
edge: "w",
|
||||||
|
dx: 5000,
|
||||||
|
dy: 0,
|
||||||
|
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "north clamps, south edge holds",
|
||||||
|
edge: "n",
|
||||||
|
dx: 0,
|
||||||
|
dy: 5000,
|
||||||
|
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
|
||||||
|
},
|
||||||
|
// east/south clamp the size but never touch the origin
|
||||||
|
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
|
||||||
|
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
|
||||||
|
// a corner clamping on BOTH axes at once
|
||||||
|
{
|
||||||
|
name: "north-west clamps both",
|
||||||
|
edge: "nw",
|
||||||
|
dx: 5000,
|
||||||
|
dy: 5000,
|
||||||
|
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
|
||||||
|
},
|
||||||
|
// a zero drag must be a no-op, or a click on a handle would nudge the window
|
||||||
|
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
|
||||||
|
|
||||||
|
console.log("[resize-math] frame arithmetic:\n");
|
||||||
|
console.log(" case edge got verdict");
|
||||||
|
let failed = 0;
|
||||||
|
for (const c of CASES) {
|
||||||
|
const got = resizeFrame(c.edge, START, c.dx, c.dy);
|
||||||
|
const ok = fmt(got) === fmt(c.want);
|
||||||
|
if (!ok) failed++;
|
||||||
|
console.log(
|
||||||
|
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural check: every edge must be exercised above, so adding a ninth
|
||||||
|
// handle without a case cannot slip through.
|
||||||
|
const covered = new Set(CASES.map((c) => c.edge));
|
||||||
|
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
|
||||||
|
if (missing.length) {
|
||||||
|
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clamp must never produce a frame smaller than the minimum, whatever the
|
||||||
|
// drag — a cheap sweep over every edge and a range of deltas.
|
||||||
|
for (const edge of RESIZE_EDGES) {
|
||||||
|
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
|
||||||
|
const got = resizeFrame(edge, START, d, d);
|
||||||
|
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
|
||||||
|
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
console.log(`\n[resize-math] ${failed} failure(s)`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log("\n[resize-math] clean");
|
||||||
@@ -93,6 +93,31 @@ export function windowControlHandlers(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
|
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
|
||||||
|
|
||||||
|
// ---- STOPGAP: synthesized resize ----
|
||||||
|
// A frameless GTK window has no resize borders, and electrobun exposes no
|
||||||
|
// native resize gesture (it has startWindowMove and nothing else — the
|
||||||
|
// shipped libNativeWrapper does not even link gtk_window_begin_resize_drag).
|
||||||
|
// So the webview drags its own handles and moves the frame itself. Both are
|
||||||
|
// synchronous FFI reads/writes, like isMaximized above, so each drag tick
|
||||||
|
// stays one round trip. DELETE WITH THE REST OF THE HACK — the checklist is
|
||||||
|
// in ui/src/app/skins/7like/window-resize/window-resize.ts.
|
||||||
|
windowGetFrame: () =>
|
||||||
|
wrap(() => getWindow()?.getFrame() ?? { x: 0, y: 0, width: 0, height: 0 }),
|
||||||
|
|
||||||
|
// Params are annotated from the schema rather than inferred: this factory
|
||||||
|
// returns a bare object literal, so it gets none of the contextual typing
|
||||||
|
// the inline handler sets inside defineRPC<AppRPC> enjoy.
|
||||||
|
windowSetFrame: ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) =>
|
||||||
|
wrap(() => {
|
||||||
|
getWindow()?.setFrame(x, y, width, height);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,21 @@ export type AppRPC = {
|
|||||||
params: Record<string, never>;
|
params: Record<string, never>;
|
||||||
response: Result<{ maximized: boolean }>;
|
response: Result<{ maximized: boolean }>;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* STOPGAP — the two halves of the synthesized resize. A frameless GTK
|
||||||
|
* window has no resize borders and electrobun exposes no native resize
|
||||||
|
* gesture, so the webview drags its own handles and moves the frame
|
||||||
|
* itself. Delete both with the rest of the hack; see the removal
|
||||||
|
* checklist in ui/src/app/skins/7like/window-resize/window-resize.ts.
|
||||||
|
*/
|
||||||
|
windowGetFrame: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: Result<{ x: number; y: number; width: number; height: number }>;
|
||||||
|
};
|
||||||
|
windowSetFrame: {
|
||||||
|
params: { x: number; y: number; width: number; height: number };
|
||||||
|
response: Result;
|
||||||
|
};
|
||||||
// auto-updater (electrobun Updater)
|
// auto-updater (electrobun Updater)
|
||||||
/** Main window: re-run the update check on demand (menu item). */
|
/** Main window: re-run the update check on demand (menu item). */
|
||||||
checkForUpdate: {
|
checkForUpdate: {
|
||||||
|
|||||||
@@ -616,6 +616,18 @@ html.win7 body {
|
|||||||
border-radius: 0;
|
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
|
/* 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
|
(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
|
clipped by the parent's border or radius — the caption buttons sit flush
|
||||||
|
|||||||
@@ -63,6 +63,11 @@
|
|||||||
<app-overwrite-dialog [request]="req" />
|
<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-context-menu />
|
||||||
<app-toasts />
|
<app-toasts />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { TitleBar } from './title-bar/title-bar';
|
|||||||
import { ToastService } from '@core/toast.service';
|
import { ToastService } from '@core/toast.service';
|
||||||
import { Toasts } from './toasts/toasts';
|
import { Toasts } from './toasts/toasts';
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||||
|
import { WindowResize } from './window-resize/window-resize';
|
||||||
import { TransfersService } from '@core/transfers.service';
|
import { TransfersService } from '@core/transfers.service';
|
||||||
import { UpdaterService } from '@core/updater.service';
|
import { UpdaterService } from '@core/updater.service';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ import { UpdaterService } from '@core/updater.service';
|
|||||||
TransferBar,
|
TransferBar,
|
||||||
TitleBar,
|
TitleBar,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
|
WindowResize,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
host: {
|
host: {
|
||||||
|
|||||||
@@ -381,5 +381,10 @@
|
|||||||
<app-confirm-dialog [request]="req" />
|
<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 />
|
<app-toasts />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SettingsService } from '@core/settings.service';
|
|||||||
import { ShortcutsService } from '@core/shortcuts.service';
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
import { TitleBar } from '../title-bar/title-bar';
|
import { TitleBar } from '../title-bar/title-bar';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
|
import { WindowResize } from '../window-resize/window-resize';
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -74,7 +75,7 @@ function fromDraft(d: Draft): SavedConnection {
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar],
|
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar, WindowResize],
|
||||||
templateUrl: './connection-manager.html',
|
templateUrl: './connection-manager.html',
|
||||||
host: {
|
host: {
|
||||||
'(window:keydown.escape)': 'onEscape()',
|
'(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 />
|
<app-toasts />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ShortcutsService } from '@core/shortcuts.service';
|
|||||||
import { ToastService } from '@core/toast.service';
|
import { ToastService } from '@core/toast.service';
|
||||||
import { TitleBar } from '../title-bar/title-bar';
|
import { TitleBar } from '../title-bar/title-bar';
|
||||||
import { Toasts } from '../toasts/toasts';
|
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). */
|
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
|
||||||
const DRAFT_DEBOUNCE_MS = 400;
|
const DRAFT_DEBOUNCE_MS = 400;
|
||||||
@@ -32,7 +33,7 @@ const DRAFT_DEBOUNCE_MS = 400;
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, Toasts, TitleBar],
|
imports: [NgIcon, Toasts, TitleBar, WindowResize],
|
||||||
templateUrl: './editor-window.html',
|
templateUrl: './editor-window.html',
|
||||||
host: {
|
host: {
|
||||||
'(window:keydown)': 'onKeydown($event)',
|
'(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user