Skip to content

feat(move): make the borderless window movable — right-drag, Alt+arrow nudge, explicit x/y - #13

Merged
h4x0r merged 4 commits into
mainfrom
worktree-borderless-window-move
Aug 9, 2026
Merged

feat(move): make the borderless window movable — right-drag, Alt+arrow nudge, explicit x/y#13
h4x0r merged 4 commits into
mainfrom
worktree-borderless-window-move

Conversation

@h4x0r

@h4x0r h4x0r commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

The problem

Borderless mode builds new BrowserWindow({ frame: false }) and loads an arbitrary remote page straight into it. There is no OS frame, and the drag handle that makes the normal window movable lives in our electron/toolbar.html (-webkit-app-region: drag), which borderless deliberately never loads. Nothing set x/y either. So the window was nailed wherever Electron first put it.

The usual Electron answer — app-region: drag in the page's CSS — is unavailable by construction: the page belongs to someone else, and editing it would alter the thing being filmed. Electron's own frameless-window docs don't cover the third-party-page case.

The fix

Move the decision out of the renderer and into the main process, which sees input before Chromium dispatches it to the page and can cancel it. Three ways to move, all live in both modes and inert in fullscreen/kiosk:

Gesture Effect
Right-drag pick the window up anywhere on it
Escape mid-drag drop it back where the drag started
Alt + arrow nudge 1 px
Alt + Shift + arrow nudge 10 px

Plus explicit placement for repeatability across takes: X/Y, --x/--y, or x/y in a scene file. Signed, so a display left of or above the primary is reachable; the pair is all-or-nothing, and an incomplete pair falls back to the OS's placement.

The page never receives the grab, never scrolls on our arrow keys, and is never modified.

Why the right button and not Alt+drag

Alt+drag was the intended design and it does not work. Electron 43 emits no modifiers field on before-mouse-event or input-event — measured on 43.3.0, an Alt+click payload is byte-identical to a plain one:

BEFORE-MOUSE {"type":"mouseDown","clickCount":1,"button":"left","globalX":500,"globalY":400,"x":300,"y":200}   # plain
BEFORE-MOUSE {"type":"mouseDown","clickCount":1,"button":"left","globalX":500,"globalY":400,"x":300,"y":200}   # Alt held

modifiers is on the MouseInputEvent structure because that type is also the input to webContents.sendInputEvent; being accepted going in is not being populated coming out. button is reported reliably (left/right/none), so the button is the discriminator and the left button stays entirely the page's. The keyboard channel is unaffected — before-input-event carries real alt/shift, which is why the nudges work to the pixel.

Drag mechanics

The window keeps the grabbed point under the cursor, so the cursor cannot leave the window mid-drag and the ending mouse-up is always delivered — even though the page never received the mouse-down and so never took pointer capture. Position is driven by polling screen.getCursorScreenPoint() at ~60 Hz rather than by mouse-move payloads, and the drag is torn down on mouse-up, window blur, close, or Escape.

Verification

TDD, RED then GREEN as separate commits. Decisions live in src/move.js under the existing 100% coverage gate; electron/main.js stays a Humble Object that only wires them. 122 tests, 100% lines/branches/functions/statements, eslint and prettier clean.

Unit tests are mine, so they are not independent. Verified separately on both platforms by synthesizing real input and reading the resulting window position back from the OS — neither the stimulus nor the measurement produced by this code.

macOS (Quartz CGEvent into the HID tap; measured by CGWindowListCopyWindowInfo), run against both borderless and framed builds:

start x=320 y=180 w=1440 h=900
ok   plain left drag leaves it put: got (320, 180), expected (320, 180)
ok   right drag moves by the cursor delta: got (460, 270), expected (460, 270)
ok   window stops following after mouse-up: got (460, 270), expected (460, 270)
ok   alt+right nudges 1px: got (461, 270), expected (461, 270)
ok   alt+shift+down nudges 10px: got (461, 280), expected (461, 280)
VERDICT: PASS

Windows 11 ARM64 (build 26200) in a Parallels VM, against Electron 43.3.0 win32-arm64, cloning this branch — mouse_event/keybd_event measured by DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS):

ok   plain left drag leaves it put: got 312,180, expected 312,180
ok   right drag moves by the cursor delta: got 452,270, expected 452,270
ok   window stops following after mouse-up: got 452,270, expected 452,270
ok   alt+right nudges 1 DIP: got 453,270, expected 453,270
ok   alt+shift+down nudges 10 DIP: got 453,280, expected 453,280

requested (320,180) -> visible (319,180) size 802x501
requested (100, 50) -> visible ( 99, 50) size 802x501
delta measured (220,130) expected (220,130) -> ok
VERDICT: PASS

The plain-left-drag line is the positive control: it proves the harness can tell "the gesture worked" from "any drag moves it". It is what caught the original Alt+drag implementation, which passed every unit test and moved nothing.

Two Windows measurement traps found on the way, neither a defect in this change: GetWindowRect counts the invisible resize border (~8 px per side) and so disagrees with the visible frame — DWMWA_EXTENDED_FRAME_BOUNDS is the right call; and Windows clamps a window taller than the monitor work area, so an oversized HEIGHT comes back smaller than requested.

Still not verified

  • Linux. On Wayland setPosition and getCursorScreenPoint are documented non-functional, so placement and drag will not work there (X11 should be fine); most Linux WMs already offer Super/Alt+drag.
  • Non-100% display scaling. Both test machines reported scale 1.0. Coordinates are DIP, so at 125%/150% DIP-to-physical rounding may shift exact placement by a pixel.

Separate pre-existing bug, not fixed here

src/scene.js omits borderless from BOOL_FIELDS, so "borderless": true in a scene file is silently stripped — despite src/config.js documenting "same env-over-scene-over-default precedence as kiosk". Left alone to keep this diff to the ask; worth a one-line follow-up.

🤖 Generated with Claude Code

h4x0r and others added 4 commits August 9, 2026 10:06
Borderless mode loads a third-party page into a frame: false window and
loads none of our own chrome, so the -webkit-app-region: drag strip in
electron/toolbar.html is absent and nothing on screen can be grabbed.
Nothing in the config sets x/y either, so the window is stuck wherever
Electron first placed it.

These tests specify the three missing ways to move it:

  - src/move.js       Alt+drag and Alt+arrow nudge classification, plus the
                      grab-offset invariant that keeps the grabbed point
                      under the cursor for the whole drag
  - src/config.js     X / Y environment placement (signed, unlike WIDTH/HEIGHT)
  - src/scene.js      x / y scene fields (signed integers, zero allowed)
  - src/cli.js        --x / --y flags

All fail against the current implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cement

Three ways to move a window that offers nothing to grab, all live in both
framed and borderless modes and inert in fullscreen/kiosk:

  - right-drag anywhere picks it up; Escape mid-drag restores the start position
  - Alt+arrow nudges 1px, Alt+Shift+arrow 10px, for final framing
  - X/Y, --x/--y, or x/y in a scene file place it outright (signed, so a display
    left of or above the primary is reachable; the pair is all-or-nothing)

Gestures are classified in the main process from Chromium's pre-dispatch
before-mouse-event / before-input-event and then cancelled, so the loaded page
never receives the grab, never scrolls on our arrow keys, and is never
modified — the point being that borderless loads a page we do not own, which
rules out the usual -webkit-app-region: drag answer.

The drag is the RIGHT button, not the expected Alt+drag, because Electron 43
emits no modifiers on before-mouse-event or input-event: measured, an Alt+click
payload is byte-identical to a plain one, so a modifier chord is undetectable on
the mouse channel. (modifiers appears on the MouseInputEvent structure because
that type is also the INPUT to webContents.sendInputEvent — accepted going in is
not populated coming out.) button is reported reliably, so it discriminates, and
the left button stays entirely the page's.

Drag mechanics: the window keeps the grabbed point under the cursor, so the
cursor cannot leave the window mid-drag and the ending mouse-up is always
delivered even though the page never took pointer capture. Position is driven by
polling screen.getCursorScreenPoint() at ~60Hz rather than by mouse-move
payloads.

Decisions live in src/move.js under the 100% coverage gate; electron/main.js
only wires them. 122 tests, coverage 100% lines/branches/functions/statements.

Verified beyond the unit tests: on macOS, real HID input synthesized with Quartz
CGEvent, and the resulting position read back from the window server
(CGWindowListCopyWindowInfo) — neither side produced by this code. Both modes:
plain left-drag leaves the window put (negative control), right-drag moves it by
exactly the cursor delta, the window stops following after mouse-up, Alt+Right
moves +1px, Alt+Shift+Down moves +10px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran the same end-to-end checks on Windows 11 ARM64 (build 26200) in a Parallels
VM against Electron 43.3.0 win32-arm64, driving the app with real synthesized
input (mouse_event / keybd_event) and measuring with
DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS). All pass: placement honours
--x/--y, a plain left-drag does not move the window, a right-drag moves it by
exactly the cursor delta, the window stops following after mouse-up, and the
nudges move 1px and 10px.

Also records two Windows measurement traps found while doing it, neither a
defect in this change: GetWindowRect counts the invisible resize border (~8px
per side) so it disagrees with the visible frame, and Windows clamps a window
taller than the monitor work area.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Minor bump: adds the window-move feature (right-drag, Alt+arrow nudge, explicit
x/y placement). No breaking changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@h4x0r
h4x0r marked this pull request as ready for review August 9, 2026 17:50
@h4x0r
h4x0r merged commit 59f9309 into main Aug 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant