9term continuous scrolling bug

/usr/local/plan9 · Wed 26 Aug 2026 06:50:26 · 12 messages
5b1b9bfe-d316-437f-8171-4928c0d29c6b

You Tue 25 Aug 2026 14:34:14

Bug report below. Look at the source code and see if you can find the problem.

I have a USB mouse attached to OSX (a magic mouse or a 3rd party Beatus mouse), all works well but if I leave a 9term window too quickly after typing a command it scolls continuously, selecting old window contents.

The fix seems to be to return to the scolling window, and hit return several times and click the mouse at the prompt.

I assume it misses the mouse up event before the mouse leaves the window, and a fix might be forcing a mouse-up when the cursor leaves a window (does that break something else?).

Interestingly this never happens with a wired mouse.

Anyone else seen this, and more importantly, anyone have any thoughts on where to dig around to try and fix it?

Assistant Tue 25 Aug 2026 14:34:23

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?