Skip to content

Carry the Windows notch across monitors by its handle - #287

Merged
vinzdg merged 3 commits into
vinzdg:mainfrom
RawJat:feat/windows-carry-across-screens
Sep 20, 2026
Merged

vinzdg merged 3 commits into
vinzdg:mainfrom
RawJat:feat/windows-carry-across-screens

Conversation

@RawJat

@RawJat RawJat commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

The move handle only ever offered the edges of one monitor. begin_move took the notch's screen once, read every target against it, and on release wrote the edge alone. A pointer carried onto a second monitor still picked an edge of the first, and the overlay stayed behind — drawn there at the wrong size, since its figures belonged to the other screen.

The zones now follow the pointer. Whichever screen it is over, the overlay moves there, the edge is read against that screen's work area, and letting go writes the screen as well as the edge. The right edge of another screen counts as a move too, though the edge has the same name. Over the gap a shorter screen leaves beside a taller one, the carry keeps the screen it was last over rather than flicking between them.

Why now: since the Alt+drag change (#276), the drag no longer leaves its own edge, so this becomes the one gesture that takes the notch to another monitor; the Screen picker in Settings is the other way. The Mac's handle is single-screen — beginMove takes currentScreen() once — so this goes past it rather than porting it.

Scale. The overlay is pinned in physical pixels, and pinned again if Windows resizes it for a new DPI after the move, the way place_notch pins the notch. The silhouette is in logical pixels, so it keeps its size on a screen at another scale — the size the notch will land at. Screens are told apart by where they are rather than by name, which the platform is not obliged to report.

Moving between scales without it showing. Crossing onto a monitor at another scale, Windows first resizes a window by the ratio of the two scales, and the fix-up above then puts it right — which is correct, but played out on screen: the zones shrank and grew again on every crossing, and a notch landing on the other monitor visibly jumped sizes. So the overlay is hidden for the crossing and shown again once the new zones are drawn. The overlay is also made unfocusable, like the notch, since it is shown again on every crossing.

The notch needs more care, because hiding its window does not work: a hidden WebView2 stops painting and throttles its timers, so the page re-zoomed itself for the new pixel ratio only once shown again, in plain view. Instead the window stays up and the page empties itself — the window is transparent, so that is an invisible notch — while the WebView keeps doing its layout. It is revealed on the first report_dpr from the end of a burst of resizes that needs no further correction, i.e. once the layout has actually stopped changing, with a 700 ms fallback so it can never stay hidden. A landing at the same scale is not hidden at all, and neither is any other placement.

Testing: cargo test 104 pass, 0 fail, 2 ignored, including a new test for which screen a carry is over, the shared border between two, and the gap beside a shorter one. Clippy unchanged.

Depends on #276 (Alt+drag): both touch the landing in begin_move, so this is built on top of it and should go in after. #269 moves dropzones.rs and main.rs to desktop/; #239, #257 and #210 touch main.rs in other functions.

A plain press on the pill plus 4 px of movement dragged the window
anywhere, and letting go snapped it to the nearest edge of whichever
monitor it was over, saving one position that every edge shared. Since
vinzdg#244 a click on a ring refreshes it, so a click that slipped moved the
notch instead (vinzdg#251); and since vinzdg#246 the move handle exists to change
edge, so the drag was doing its job too.

The drag now needs Alt held, and without it every press on the pill is a
click. It follows the pointer along the edge the notch is on and nowhere
else, inside the work area, and never changes edge: the move handle and
the Edge picker in Settings do that. It is the Mac's ⌥-drag
(NotchWindowController.dragged), which only ever moves alongOffset.

Each edge now remembers its own place, as the Mac keeps notchOffset per
edge. A move to another edge lands where the notch was last left on that
edge, centred if it has never been slid along it, and Recentre centres
only the edge it is on. A carry used to land centred on purpose, because
every edge shared one position and a carry dragged the old edge's
fraction along with it; with a place per edge that reason has gone.

The place is kept as a fraction of the edge rather than the Mac's points,
so an existing notch_y becomes the place for the edge it was saved on
without needing a screen to convert it, and a change of resolution keeps
the notch in the same part of the edge. notch_y is read once and never
written again.

One thing is lost: dragging no longer takes the notch to another monitor.
The Screen picker in Settings does, as on the Mac, and its caption no
longer claims the drag can. The drag hint is rewritten in the Mac's own
words, with its ru, zh-Hans, zh-Hant, ja and uk translations taken from
Localizable.xcstrings and ⌥ read as Alt; the Mac has no Korean, so that
sentence is new. The Ukrainian hint had been keyed to an older English
sentence and never showed; it does now.
The move handle only ever offered the edges of one monitor. begin_move
took the notch's screen once, read every target against it, and on
release wrote the edge alone, so a pointer carried onto a second monitor
still picked an edge of the first, and the overlay stayed behind — drawn
there at the wrong size, since its figures belonged to the other screen.

The zones now follow the pointer. Whichever screen it is over, the overlay
moves there, the edge is read against that screen's work area, and
letting go writes the screen as well as the edge; the right edge of
another screen is a move too, though the edge has the same name. Over the
gap a shorter screen leaves beside a taller one, the carry keeps the
screen it was last over rather than flicking between them.

With the drag held to its own edge, this is the one gesture left that
takes the notch to another monitor; the Screen picker in Settings is the
other way. The Mac's handle is single-screen — beginMove takes
currentScreen() once — so this goes past it rather than porting it.

The overlay is pinned in physical pixels, and pinned again if Windows
resizes it for a new scale after the move, as place_notch does for the
notch. On a screen at another scale that resize and its correction would
both play out as the zones shrinking and growing again, so the overlay is
hidden for the crossing and shown a couple of frames after the new zones
are sent. It is also made unfocusable, like the notch: shown again on
every screen crossed, a window that could take focus would take it from
whatever the user was working in.

The silhouette is in logical pixels, so it keeps its size on a screen at
another scale, the size the notch will land at. Screens are told apart by
where they are rather than by name, which the platform is not obliged to
report.
Arriving on a monitor at another scale, Windows resizes the notch by the
ratio of the two scales before place_notch puts it right, and the page
then re-zooms itself for the new pixel ratio a debounce later, which can
bring one more zoom correction from report_dpr. All of it played out on
screen: a notch carried to another monitor jumped sizes as it landed.

Hiding the window for the landing does not help. A hidden WebView2 stops
painting and throttles its timers, so the page only re-laid itself out
once it was shown again, in plain view — however long the delay before
showing it.

So the window stays up and the page empties itself instead: the window
is transparent, so an empty page is an invisible notch, while the WebView
keeps doing its layout. Rust waits for the page to confirm it has painted
empty before moving it, and reveals it on the first report_dpr from the
end of a burst of resizes that needs no further correction — the layout
has stopped changing — rather than after a guessed delay. A fallback
shows it after 700 ms whatever happens, numbered so it can never reveal a
later landing early.

At the same scale nothing is resized on arrival, so nothing is hidden.
Every other placement stays in sight as before.
@RawJat
RawJat requested a review from vinzdg as a code owner September 19, 2026 17:54
@vinzdg
vinzdg merged commit 107452b into vinzdg:main Sep 20, 2026
4 checks passed
@vinzdg

vinzdg commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Merged as 107452b. The DPI handling is sound and emptying the page rather than hiding the window is the right trick for WebView2 — thanks @RawJat.

I pushed one follow-up on top, 7121fb8: c.notch_monitor = mon.name.clone() wrote None when Windows wouldn't name the destination display, and None doesn't mean "this screen" — target_screen reads it as "the primary". So carrying onto an unnamed monitor was accepted and saved, then the notch left it at the next placement. Since the Settings picker already lists unnameable screens disabled for exactly that reason, the carry now keeps the edge you chose and leaves the notch where it was rather than record a move that won't survive.

Two smaller things I left alone: the 40 ms sleep inside the 16 ms carry poll in dropzones.rs:101 stalls pointer tracking on each screen crossing, and same_screen compares geometry so mirrored displays are indistinguishable. Neither is worth a change on its own.

@RawJat
RawJat deleted the feat/windows-carry-across-screens branch September 23, 2026 06:38
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.

2 participants