diff --git a/docs/architecture.md b/docs/architecture.md index 309ad4f..41e03c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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` / `windowClose` / `windowIsMaximized` requests, built by `windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by diff --git a/docs/gotchas.md b/docs/gotchas.md index 0dd9958..a65de33 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -266,6 +266,31 @@ touching the adjacent code. 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` 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 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 diff --git a/docs/platform-notes.md b/docs/platform-notes.md index 918ca9b..af839ca 100644 --- a/docs/platform-notes.md +++ b/docs/platform-notes.md @@ -8,7 +8,9 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets | 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` | | 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` | @@ -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 (`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) Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB diff --git a/package.json b/package.json index e583997..4da9d06 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build:ui": "bun run --cwd ui 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", - "postinstall": "bun scripts/gen-version.ts", + "postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts", "release": "bun scripts/release.ts" }, "dependencies": { diff --git a/scripts/check-electrobun-resize.ts b/scripts/check-electrobun-resize.ts new file mode 100644 index 0000000..f9b4ac1 --- /dev/null +++ b/scripts/check-electrobun-resize.ts @@ -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); +} diff --git a/scripts/resize-math-probe.ts b/scripts/resize-math-probe.ts new file mode 100644 index 0000000..d4a747d --- /dev/null +++ b/scripts/resize-math-probe.ts @@ -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"); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index e318047..3a670f1 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -93,6 +93,31 @@ export function windowControlHandlers( }), 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 enjoy. + windowSetFrame: ({ + x, + y, + width, + height, + }: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) => + wrap(() => { + getWindow()?.setFrame(x, y, width, height); + return {}; + }), }; } diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index f342959..3482633 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -168,6 +168,21 @@ export type AppRPC = { params: Record; 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; + 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) /** Main window: re-run the update check on demand (menu item). */ checkForUpdate: { diff --git a/ui/src/app/skins/7like/7like.css b/ui/src/app/skins/7like/7like.css index 54e0fe5..4c77198 100644 --- a/ui/src/app/skins/7like/7like.css +++ b/ui/src/app/skins/7like/7like.css @@ -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 diff --git a/ui/src/app/skins/7like/app.html b/ui/src/app/skins/7like/app.html index ec12cfd..1bb1c81 100644 --- a/ui/src/app/skins/7like/app.html +++ b/ui/src/app/skins/7like/app.html @@ -63,6 +63,11 @@ } + + + diff --git a/ui/src/app/skins/7like/app.ts b/ui/src/app/skins/7like/app.ts index cb69d6c..c5fe72e 100644 --- a/ui/src/app/skins/7like/app.ts +++ b/ui/src/app/skins/7like/app.ts @@ -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: { diff --git a/ui/src/app/skins/7like/connection-manager/connection-manager.html b/ui/src/app/skins/7like/connection-manager/connection-manager.html index aed9f8c..51eb6e0 100644 --- a/ui/src/app/skins/7like/connection-manager/connection-manager.html +++ b/ui/src/app/skins/7like/connection-manager/connection-manager.html @@ -381,5 +381,10 @@ } + + + diff --git a/ui/src/app/skins/7like/connection-manager/connection-manager.ts b/ui/src/app/skins/7like/connection-manager/connection-manager.ts index 45ad7ab..13c2890 100644 --- a/ui/src/app/skins/7like/connection-manager/connection-manager.ts +++ b/ui/src/app/skins/7like/connection-manager/connection-manager.ts @@ -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()', diff --git a/ui/src/app/skins/7like/editor-window/editor-window.html b/ui/src/app/skins/7like/editor-window/editor-window.html index 2b774ba..8645578 100644 --- a/ui/src/app/skins/7like/editor-window/editor-window.html +++ b/ui/src/app/skins/7like/editor-window/editor-window.html @@ -203,5 +203,10 @@ } } + + + diff --git a/ui/src/app/skins/7like/editor-window/editor-window.ts b/ui/src/app/skins/7like/editor-window/editor-window.ts index b5bc7bd..3e70811 100644 --- a/ui/src/app/skins/7like/editor-window/editor-window.ts +++ b/ui/src/app/skins/7like/editor-window/editor-window.ts @@ -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)', diff --git a/ui/src/app/skins/7like/window-resize/resize-math.ts b/ui/src/app/skins/7like/window-resize/resize-math.ts new file mode 100644 index 0000000..0dde3c6 --- /dev/null +++ b/ui/src/app/skins/7like/window-resize/resize-math.ts @@ -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), + }; +} diff --git a/ui/src/app/skins/7like/window-resize/window-resize.ts b/ui/src/app/skins/7like/window-resize/window-resize.ts new file mode 100644 index 0000000..63a62d8 --- /dev/null +++ b/ui/src/app/skins/7like/window-resize/window-resize.ts @@ -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: `` 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 = { + 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) { +
+ } + `, + // 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 { + 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; + } + } +}