feat(move): make the borderless window movable — right-drag, Alt+arrow nudge, explicit x/y - #13
Merged
Merged
Conversation
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
marked this pull request as ready for review
August 9, 2026 17:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ourelectron/toolbar.html(-webkit-app-region: drag), which borderless deliberately never loads. Nothing setx/yeither. So the window was nailed wherever Electron first put it.The usual Electron answer —
app-region: dragin 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:
Plus explicit placement for repeatability across takes:
X/Y,--x/--y, orx/yin 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
modifiersfield onbefore-mouse-eventorinput-event— measured on 43.3.0, an Alt+click payload is byte-identical to a plain one:modifiersis on theMouseInputEventstructure because that type is also the input towebContents.sendInputEvent; being accepted going in is not being populated coming out.buttonis 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-eventcarries realalt/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.jsunder the existing 100% coverage gate;electron/main.jsstays 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
CGEventinto the HID tap; measured byCGWindowListCopyWindowInfo), run against both borderless and framed builds:Windows 11 ARM64 (build 26200) in a Parallels VM, against Electron 43.3.0
win32-arm64, cloning this branch —mouse_event/keybd_eventmeasured byDwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS):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:
GetWindowRectcounts the invisible resize border (~8 px per side) and so disagrees with the visible frame —DWMWA_EXTENDED_FRAME_BOUNDSis the right call; and Windows clamps a window taller than the monitor work area, so an oversizedHEIGHTcomes back smaller than requested.Still not verified
setPositionandgetCursorScreenPointare documented non-functional, so placement and drag will not work there (X11 should be fine); most Linux WMs already offer Super/Alt+drag.Separate pre-existing bug, not fixed here
src/scene.jsomitsborderlessfromBOOL_FIELDS, so"borderless": truein a scene file is silently stripped — despitesrc/config.jsdocumenting "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