I'll dig into the source. Let me look at the macOS devdraw mouse handling and the 9term/text selection code.
I found a concrete bug that fits every clue, and it's in the macOS devdraw backend, not in 9term.
The bug: scrollWheel: synthesizes a button press with no release
mac-screen.m:606:
- (void)scrollWheel:(NSEvent*)e
{
CGFloat s;
s = [e scrollingDeltaY];
if(s > 0.0f)
[self sendmouse:8];
else if (s < 0.0f)
[self sendmouse:16];
}
Plan 9 encodes wheel-up/wheel-down as buttons 4 and 5 (bits 8 and 16), and every other backend delivers them as a press followed by a release. X11 gets that for free from the server — x11-screen.c:405 handles ButtonPress and ButtonRelease for buttons 4/5 alike, and _xtoplan9mouse clears the bit on release. The touchpad tap code right above in this same file even does it by hand (mac-screen.m:679): sendmouse:2; sendmouse:0;.
scrollWheel: never sends the 0. So after any scroll, devdraw's c->mouse.last.buttons is 8 (or 16) and stays there. In srv.c:511 the state is only revised when another event arrives:
if(testnocoalesce || c->mouse.wi == c->mouse.ri || c->mouse.last.buttons != b){
Why it only sticks when you leave the window
The state is repaired by the next mouse event — and once the pointer leaves the window, there are no more events. There's no mouseExited:/tracking area in the file (I grepped), and windowDidResignKey (mac-screen.m:591) only aborts compose; unlike windowDidBecomeKey it doesn't sendmouse:0. So a phantom button "held down" at the moment the pointer exits is held down forever, exactly as you guessed.
Two things make it worse:
sendmouse (mac-screen.m:719) reports mouseLocationOutsideOfEventStream — the pointer's position now, not the event's locationInWindow. gfx_mousetrack then clamps that into mouserect. So an event that arrives after the pointer has left is reported at a window edge — and off the left edge means x clamps to 0, which is inside 9term's scrollbar.
getmouse (mac-screen.m:701) likewise uses [NSEvent pressedMouseButtons], the global instantaneous state, rather than the event's own type/buttonNumber. A late-arriving event samples whatever button happens to be down at dequeue time — including one you're pressing in a different application.
Why wireless and not wired
A Magic Mouse (and most third-party touch/inertial mice) keeps emitting scrollWheel: events after you stop steering it: stray touch-surface deltas as your hand lifts off, plus the momentum phase, which macOS delivers to the view where the gesture began for a second or two — after the pointer has left that window. That's precisely the "leave too quickly" window. A wired mouse with a mechanical wheel emits nothing once you let go, so the view never receives a post-exit event and the phantom press is never planted.
Why it looks like continuous scrolling and selection
Every consumer spins on the button word, and with a value that can no longer change, they never exit:
- frselect.c:102 —
}while(mc->m.buttons == b); with f->scroll set. The stale mp is above f->r.min.y, so it auto-scrolls backwards and extends the selection on every iteration. This is the "scrolls continuously, selecting old window contents".
- scrl.c:180 —
}while(w->mc.m.buttons & (1<<(but-1))); then while(w->mc.m.buttons) readmouse(&w->mc); at line 181, which blocks forever on a stuck non-zero value.
- wind.c:1108 — the tail of
wselect, same shape.
And clicking at the prompt cures it because that click is the first event the view has seen since the pointer left; it rewrites the button state and every one of those loops falls out.
The fix
Send the release, matching X11 and the tap code:
- (void)scrollWheel:(NSEvent*)e
{
CGFloat s;
s = [e scrollingDeltaY];
if(s > 0.0f){
[self sendmouse:8];
[self sendmouse:0];
}else if(s < 0.0f){
[self sendmouse:16];
[self sendmouse:0];
}
}
One caution on your proposed fix: don't force a mouse-up when the cursor leaves the window. Dragging a selection past the window edge is exactly how frselect's auto-scroll is meant to be driven — in 9term, acme, and sam — so cancelling the button on exit would break sweeping a selection beyond the visible text, which is a heavily used gesture. If you want belt-and-braces beyond the scrollWheel: fix, put it in windowDidResignKey (mirroring windowDidBecomeKey's sendmouse:0) and gate it on [NSEvent pressedMouseButtons] == 0, so a genuine drag is untouched.
Two related defects in the same file, worth fixing while you're there but not the cause here: flagsChanged: (mac-screen.m:634) does the 2/3 bit swap but skips mouseswap(), unlike getmouse; and both keep the high bits of pressedMouseButtons, so a mouse with side buttons (button 4/5 → bits 3/4 → 8/16) reports them as wheel scrolls. That last one could give your Beatus mouse spurious scrolling all on its own.
Want me to apply the scrollWheel: patch?