@ 261105.1

261010.1 · e0700df7ab5dbc4484ece1a32812f6e2602afd5b

feat(gpm): support Linux console mouse (fixes #297)

Use GPM when available on Linux virtual consoles, falling back silently to terminal mouse protocols otherwise.

Document that Debian and Ubuntu users need the gpm package installed for /dev/gpmctl.

reading declarations

README.md

@@ -151,6 +151,14 @@ avoiding repeated sequences or drawing the same cell on refresh updates. Mouse tracking, buttons, and even wheel mice are supported on most terminal emulators, as well as Windows. +On the Linux virtual console, _Tcell_ can receive mouse events through GPM+when the `gpm` daemon is installed and running.  For example, on Debian or+Ubuntu systems install it with `apt install gpm`; the daemon provides+`/dev/gpmctl`, which _Tcell_ uses automatically when available.  If GPM is+not installed, not running, or the application is not attached to a virtual+console, mouse enablement remains best-effort and falls back to the usual+terminal mouse protocols.+ ## Bracketed Paste  Terminals that support support it, can use bracketed paste.

_demos/mouse.go

@@ -104,6 +104,34 @@ func drawSelect(s tcell.Screen, x1, y1, x2, y2 int, sel bool) { 	} } +type savedMouseMarker struct {+	x, y  int+	str   string+	style tcell.Style+	valid bool+}++func drawMouseMarker(s tcell.Screen, marker *savedMouseMarker, x, y int, style tcell.Style) {+	str, cellStyle, width := s.Get(x, y)+	if width == 0 {+		return+	}+	marker.x = x+	marker.y = y+	marker.str = str+	marker.style = cellStyle+	marker.valid = true+	s.Put(x, y, string(tcell.RuneBullet), style)+}++func restoreMouseMarker(s tcell.Screen, marker *savedMouseMarker) {+	if !marker.valid {+		return+	}+	s.Put(marker.x, marker.y, marker.str, marker.style)+	marker.valid = false+}+ // This program just shows simple mouse and keyboard events.  Press ESC twice to // exit. func main() {@@ -148,6 +176,8 @@ func main() { 		Foreground(color.MidnightBlue).Background(color.LightCoral) 	keyDownStyle := style.Foreground(color.Green) 	keyUpStyle := style+	mouseMarkerStyle := tcell.StyleDefault.+		Foreground(color.White).Background(color.Red)  	mx, my := -1, -1 	ox, oy := -1, -1@@ -155,6 +185,8 @@ func main() { 	w, h := s.Size() 	lchar := '*' 	bstr := ""+	mouseButton := tcell.ButtonNone+	var mouseMarker savedMouseMarker 	lks := "" 	lkey := "" 	kcnt := 0@@ -166,7 +198,7 @@ func main() {  	for { 		drawBox(s, 1, 1, 42, 10, style, ' ')-		s.PutStrStyled(2, 2, "Press Ctrl-Q to Quit, C to clear.", style)+		s.PutStrStyled(2, 2, "Move/click/drag mouse. Ctrl-Q quits.", style) 		s.PutStrStyled(2, 3, fmt.Sprintf(posfmt, mx, my), style) 		s.PutStrStyled(2, 4, fmt.Sprintf(btnfmt, bstr), style) 		s.PutStrStyled(2, 5, fmt.Sprintf(keyfmt, ""), style)@@ -187,8 +219,12 @@ func main() { 		n, v := s.Terminal() 		s.PutStrStyled(2, 8, fmt.Sprintf(termFmt, n, v), style) 		s.PutStrStyled(2, 9, fmt.Sprintf(kbdFmt, keyboardProtocolName(s.KeyboardProtocol())), style)+		if mx >= 0 && my >= 0 && mouseButton == tcell.ButtonNone {+			drawMouseMarker(s, &mouseMarker, mx, my, mouseMarkerStyle)+		}  		s.Show()+		restoreMouseMarker(s, &mouseMarker) 		bstr = "" 		ev := <-s.EventQ() 		st := tcell.StyleDefault.Background(color.Red)@@ -312,8 +348,11 @@ func main() { 			if button&tcell.WheelRight != 0 { 				bstr += " WheelRight" 			}-			// Only buttons, not wheel events+			// Only buttons, not wheel events.  We keep this separately so+			// bare motion has a visible marker even when GPM or the terminal+			// does not draw a mouse pointer of its own. 			button &= tcell.ButtonMask(0xff)+			mouseButton = button 			ch := '*'  			if button != tcell.ButtonNone && ox < 0 {

console_mouse.go

new file mode 100644@@ -0,0 +1,31 @@+// Copyright 2026 The TCell Authors+//+// Licensed under the Apache License, Version 2.0 (the "License");+// you may not use this file except in compliance with the License.+// You may obtain a copy of the License at+//+//    http://www.apache.org/licenses/LICENSE-2.0+//+// Unless required by applicable law or agreed to in writing, software+// distributed under the License is distributed on an "AS IS" BASIS,+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+// See the License for the specific language governing permissions and+// limitations under the License.++package tcell++import "errors"++var errNoConsoleMouse = errors.New("native console mouse unavailable")++type consoleMouse interface {+	Start(MouseFlags) error+	Stop()+	SetSize(int, int)+}++type consoleMouseFactory func(chan<- Event) (consoleMouse, error)++var newConsoleMouse consoleMouseFactory = func(chan<- Event) (consoleMouse, error) {+	return nil, errNoConsoleMouse+}

gpm_linux.go

new file mode 100644@@ -0,0 +1,353 @@+// Copyright 2026 The TCell Authors+//+// Licensed under the Apache License, Version 2.0 (the "License");+// you may not use this file except in compliance with the License.+// You may obtain a copy of the License at+//+//    http://www.apache.org/licenses/LICENSE-2.0+//+// Unless required by applicable law or agreed to in writing, software+// distributed under the License is distributed on an "AS IS" BASIS,+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+// See the License for the specific language governing permissions and+// limitations under the License.++// This file implements a client for the GPM (general purpose mouse daemon)+// used to orchestrate mouse access and deliver mouse events to applications+// using the Linux console.  This deliberately speaks the small public GPM ABI+// directly rather than linking against libgpm, so tcell does not acquire GPL+// licensing obligations from the library.++//go:build linux++package tcell++import (+	"encoding/binary"+	"fmt"+	"io"+	"net"+	"os"+	"path/filepath"+	"strconv"+	"strings"+	"sync"+)++const (+	gpmConnectSize = 16+	gpmEventSize   = 28++	gpmButtonDown   = 32+	gpmButtonUp     = 16+	gpmButtonFourth = 8+	gpmButtonLeft   = 4+	gpmButtonMiddle = 2+	gpmButtonRight  = 1++	gpmModShift = 1+	gpmModAltGr = 2+	gpmModCtrl  = 4+	gpmModAlt   = 8++	gpmEventMove   = 1+	gpmEventDrag   = 2+	gpmEventDown   = 4+	gpmEventUp     = 8+	gpmEventSingle = 16+	gpmEventDouble = 32+	gpmEventTriple = 64+	gpmEventMFlag  = 128+	gpmEventHard   = 256+	gpmEventEnter  = 512+	gpmEventLeave  = 1024++	gpmMaxInt = 1<<31 - 1+)++var (+	gpmCtlPath = "/dev/gpmctl"+	gpmFDLink  = func(fd string) (string, error) {+		return os.Readlink(filepath.Join("/proc/self/fd", fd))+	}+)++type gpmConsoleMouse struct {+	sock   net.Conn+	eventQ chan<- Event++	mu      sync.Mutex+	flags   MouseFlags+	pressed ButtonMask+	w       int+	h       int++	stopOnce sync.Once+	stopQ    chan struct{}+	done     chan struct{}+}++func init() {+	newConsoleMouse = newGPMConsoleMouse+}++func newGPMConsoleMouse(eventQ chan<- Event) (consoleMouse, error) {+	tty, err := gpmConsoleTTY()+	if err != nil {+		return nil, err+	}+	sock, err := net.Dial("unix", gpmCtlPath)+	if err != nil {+		return nil, fmt.Errorf("failed opening gpmctl: %w", err)+	}++	cm := &gpmConsoleMouse{sock: sock, eventQ: eventQ}+	if _, err = sock.Write(gpmConnectPacket(tty)); err != nil {+		_ = sock.Close()+		return nil, fmt.Errorf("failed sending gpmctl connect: %w", err)+	}+	return cm, nil+}++func gpmConsoleTTY() (uint32, error) {+	var last error+	for _, fd := range []string{"2", "0", "1"} {+		link, err := gpmFDLink(fd)+		if err != nil {+			last = err+			continue+		}+		if tty, ok := parseGPMConsoleTTY(link); ok {+			return tty, nil+		}+		last = fmt.Errorf("not a virtual console: %s", link)+	}+	if last == nil {+		last = errNoConsoleMouse+	}+	return 0, fmt.Errorf("cannot determine Linux virtual console: %w", last)+}++func parseGPMConsoleTTY(link string) (uint32, bool) {+	link = filepath.Clean(link)+	base := filepath.Base(link)+	dir := filepath.Dir(link)+	var s string+	switch {+	case dir == "/dev" && strings.HasPrefix(base, "tty"):+		s = strings.TrimPrefix(base, "tty")+	case filepath.Base(dir) == "vc" && filepath.Dir(dir) == "/dev":+		s = base+	default:+		return 0, false+	}+	tty, err := strconv.ParseUint(s, 10, 32)+	if err != nil || tty == 0 || tty > gpmMaxInt {+		return 0, false+	}+	return uint32(tty), true+}++func gpmConnectPacket(tty uint32) []byte {+	b := make([]byte, gpmConnectSize)+	ne := binary.NativeEndian+	ne.PutUint16(b[0:], 0xffff)         // eventMask+	ne.PutUint16(b[2:], 0xffff)         // defaultMask+	ne.PutUint16(b[4:], 0)              // minMod+	ne.PutUint16(b[6:], 0xffff)         // maxMod+	ne.PutUint32(b[8:], gpmProcessID()) // pid+	ne.PutUint32(b[12:], tty)           // vc+	return b+}++func gpmProcessID() uint32 {+	pid := os.Getpid()+	if pid < 0 || pid > gpmMaxInt {+		return 0+	}+	return uint32(pid)+}++func (cm *gpmConsoleMouse) Start(flags MouseFlags) error {+	if flags&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) == 0 {+		return errNoConsoleMouse+	}+	cm.mu.Lock()+	cm.flags = flags+	cm.stopQ = make(chan struct{})+	cm.done = make(chan struct{})+	cm.mu.Unlock()++	go cm.loop()+	return nil+}++func (cm *gpmConsoleMouse) Stop() {+	cm.mu.Lock()+	stopQ := cm.stopQ+	done := cm.done+	cm.mu.Unlock()+	cm.stopOnce.Do(func() {+		if stopQ != nil {+			close(stopQ)+		}+		_ = cm.sock.Close()+	})+	if done != nil {+		<-done+	}+}++func (cm *gpmConsoleMouse) SetSize(w, h int) {+	cm.mu.Lock()+	cm.w = w+	cm.h = h+	cm.mu.Unlock()+}++func (cm *gpmConsoleMouse) loop() {+	defer func() {+		cm.mu.Lock()+		done := cm.done+		cm.mu.Unlock()+		if done != nil {+			close(done)+		}+	}()++	cm.mu.Lock()+	stopQ := cm.stopQ+	cm.mu.Unlock()++	var b [gpmEventSize]byte+	for {+		if _, err := io.ReadFull(cm.sock, b[:]); err != nil {+			return+		}+		if ev, ok := cm.eventFromPacket(b[:]); ok {+			select {+			case cm.eventQ <- ev:+			case <-stopQ:+				return+			}+		}+	}+}++func (cm *gpmConsoleMouse) eventFromPacket(b []byte) (*EventMouse, bool) {+	if len(b) < gpmEventSize {+		return nil, false+	}++	ne := binary.NativeEndian+	buttons := b[0]+	mods := b[1]+	x := int(int16(ne.Uint16(b[8:]))) - 1+	y := int(int16(ne.Uint16(b[10:]))) - 1+	typ := int(ne.Uint32(b[12:]))+	wdx := int(int16(ne.Uint16(b[24:])))+	wdy := int(int16(ne.Uint16(b[26:])))++	cm.mu.Lock()+	defer cm.mu.Unlock()++	button := gpmButtonMask(buttons, wdx, wdy)+	bare := typ & (gpmEventMove | gpmEventDrag | gpmEventDown | gpmEventUp)+	flags := cm.flags++	if button&(WheelUp|WheelDown|WheelLeft|WheelRight) != 0 {+		if flags&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) == 0 {+			return nil, false+		}+		return NewEventMouse(cm.clipX(x), cm.clipY(y), button, gpmModMask(mods)), true+	}++	switch bare {+	case gpmEventMove:+		if flags&MouseMotionEvents == 0 {+			return nil, false+		}+		cm.pressed = ButtonNone+	case gpmEventDrag:+		if flags&(MouseDragEvents|MouseMotionEvents) == 0 {+			return nil, false+		}+		if button != ButtonNone {+			cm.pressed |= button+		}+	case gpmEventDown:+		if flags&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) == 0 {+			return nil, false+		}+		cm.pressed |= button+	case gpmEventUp:+		if flags&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) == 0 {+			return nil, false+		}+		cm.pressed &^= button+	default:+		return nil, false+	}++	return NewEventMouse(cm.clipX(x), cm.clipY(y), cm.pressed, gpmModMask(mods)), true+}++func (cm *gpmConsoleMouse) clipX(x int) int {+	if cm.w <= 0 {+		return max(x, 0)+	}+	return max(min(x, cm.w-1), 0)+}++func (cm *gpmConsoleMouse) clipY(y int) int {+	if cm.h <= 0 {+		return max(y, 0)+	}+	return max(min(y, cm.h-1), 0)+}++func gpmButtonMask(buttons byte, wdx, wdy int) ButtonMask {+	var mask ButtonMask+	if buttons&gpmButtonLeft != 0 {+		mask |= Button1+	}+	if buttons&gpmButtonRight != 0 {+		mask |= Button2+	}+	if buttons&gpmButtonMiddle != 0 {+		mask |= Button3+	}+	if buttons&gpmButtonFourth != 0 {+		mask |= Button4+	}+	if buttons&gpmButtonUp != 0 || wdy > 0 {+		mask |= WheelUp+	}+	if buttons&gpmButtonDown != 0 || wdy < 0 {+		mask |= WheelDown+	}+	if wdx < 0 {+		mask |= WheelLeft+	}+	if wdx > 0 {+		mask |= WheelRight+	}+	return mask+}++func gpmModMask(mods byte) ModMask {+	var mask ModMask+	if mods&gpmModShift != 0 {+		mask |= ModShift+	}+	if mods&gpmModAltGr != 0 {+		mask |= ModAlt+	}+	if mods&gpmModCtrl != 0 {+		mask |= ModCtrl+	}+	if mods&gpmModAlt != 0 {+		mask |= ModAlt+	}+	return mask+}

gpm_linux_test.go

new file mode 100644@@ -0,0 +1,283 @@+// Copyright 2026 The TCell Authors+//+// Licensed under the Apache License, Version 2.0 (the "License");+// you may not use this file except in compliance with the License.+// You may obtain a copy of the License at+//+//    http://www.apache.org/licenses/LICENSE-2.0+//+// Unless required by applicable law or agreed to in writing, software+// distributed under the License is distributed on an "AS IS" BASIS,+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+// See the License for the specific language governing permissions and+// limitations under the License.++//go:build linux++package tcell++import (+	"encoding/binary"+	"errors"+	"io"+	"net"+	"os"+	"path/filepath"+	"testing"+	"time"+)++func gpmTestPacket(buttons, mods byte, x, y int, typ int, wdx, wdy int) []byte {+	b := make([]byte, gpmEventSize)+	ne := binary.NativeEndian+	b[0] = buttons+	b[1] = mods+	ne.PutUint16(b[8:], uint16(int16(x)))+	ne.PutUint16(b[10:], uint16(int16(y)))+	ne.PutUint32(b[12:], uint32(typ))+	ne.PutUint16(b[24:], uint16(int16(wdx)))+	ne.PutUint16(b[26:], uint16(int16(wdy)))+	return b+}++func TestParseGPMConsoleTTY(t *testing.T) {+	tests := []struct {+		link string+		want uint32+		ok   bool+	}{+		{link: "/dev/tty1", want: 1, ok: true},+		{link: "/dev/tty63", want: 63, ok: true},+		{link: "/dev/vc/4", want: 4, ok: true},+		{link: "/dev/pts/2", ok: false},+		{link: "/dev/tty", ok: false},+		{link: "/dev/tty0", ok: false},+		{link: "/tmp/tty7", ok: false},+		{link: "/dev/tty2147483648", ok: false},+	}+	for _, tt := range tests {+		got, ok := parseGPMConsoleTTY(tt.link)+		if got != tt.want || ok != tt.ok {+			t.Fatalf("parseGPMConsoleTTY(%q) = %d, %t; want %d, %t", tt.link, got, ok, tt.want, tt.ok)+		}+	}+}++func TestNewGPMConsoleMouseWithMockServer(t *testing.T) {+	oldCtlPath := gpmCtlPath+	oldFDLink := gpmFDLink+	defer func() {+		gpmCtlPath = oldCtlPath+		gpmFDLink = oldFDLink+	}()++	gpmFDLink = func(fd string) (string, error) {+		if fd == "2" {+			return "/dev/tty7", nil+		}+		return "", os.ErrNotExist+	}++	gpmCtlPath = filepath.Join(t.TempDir(), "gpmctl")+	listener, err := net.Listen("unix", gpmCtlPath)+	if err != nil {+		t.Fatalf("Listen failed: %v", err)+	}+	defer listener.Close()++	serverErr := make(chan error, 1)+	connectPacket := make(chan []byte, 1)+	go func() {+		conn, err := listener.Accept()+		if err != nil {+			serverErr <- err+			return+		}+		defer conn.Close()++		packet := make([]byte, gpmConnectSize)+		if _, err = io.ReadFull(conn, packet); err != nil {+			serverErr <- err+			return+		}+		connectPacket <- packet++		if _, err = conn.Write(gpmTestPacket(0, 0, 5, 6, gpmEventMove, 0, 0)); err != nil {+			if !errors.Is(err, net.ErrClosed) {+				serverErr <- err+			}+			return+		}+		serverErr <- nil+	}()++	eventQ := make(chan Event, 1)+	console, err := newGPMConsoleMouse(eventQ)+	if err != nil {+		t.Fatalf("newGPMConsoleMouse failed: %v", err)+	}+	cm := console.(*gpmConsoleMouse)+	cm.SetSize(80, 25)++	select {+	case packet := <-connectPacket:+		ne := binary.NativeEndian+		if ne.Uint16(packet[0:]) != 0xffff || ne.Uint16(packet[2:]) != 0xffff {+			t.Fatalf("unexpected masks in connect packet: % x", packet)+		}+		if vc := ne.Uint32(packet[12:]); vc != 7 {+			t.Fatalf("connect packet vc = %d, want 7", vc)+		}+	case <-time.After(time.Second):+		t.Fatal("timed out waiting for GPM connect packet")+	}++	if err = cm.Start(MouseMotionEvents); err != nil {+		t.Fatalf("Start failed: %v", err)+	}+	defer cm.Stop()++	select {+	case ev := <-eventQ:+		mouse, ok := ev.(*EventMouse)+		if !ok {+			t.Fatalf("event type = %T, want *EventMouse", ev)+		}+		if mouse.Buttons() != ButtonNone {+			t.Fatalf("buttons = %x, want none", mouse.Buttons())+		}+		if x, y := mouse.Position(); x != 4 || y != 5 {+			t.Fatalf("position = %d,%d, want 4,5", x, y)+		}+	case <-time.After(time.Second):+		t.Fatal("timed out waiting for mock GPM event")+	}++	select {+	case err := <-serverErr:+		if err != nil {+			t.Fatalf("mock server failed: %v", err)+		}+	case <-time.After(time.Second):+		t.Fatal("timed out waiting for mock server")+	}+}++func TestGPMButtonAndModifierMapping(t *testing.T) {+	if got := gpmButtonMask(gpmButtonLeft|gpmButtonRight|gpmButtonMiddle|gpmButtonFourth, 0, 0); got != Button1|Button2|Button3|Button4 {+		t.Fatalf("button mapping = %x", got)+	}+	if got := gpmButtonMask(gpmButtonUp|gpmButtonDown, -1, 1); got != WheelUp|WheelDown|WheelLeft {+		t.Fatalf("wheel mapping = %x", got)+	}+	if got := gpmButtonMask(0, 1, -1); got != WheelRight|WheelDown {+		t.Fatalf("wheel delta mapping = %x", got)+	}+	if got := gpmModMask(gpmModShift | gpmModAltGr | gpmModCtrl | gpmModAlt); got != ModShift|ModAlt|ModCtrl {+		t.Fatalf("modifier mapping = %x", got)+	}+}++func TestGPMEventFromPacketButtonsAndClipping(t *testing.T) {+	cm := &gpmConsoleMouse{eventQ: make(chan Event, 1), flags: MouseButtonEvents}+	cm.SetSize(10, 5)++	ev, ok := cm.eventFromPacket(gpmTestPacket(gpmButtonLeft, gpmModShift, 3, 4, gpmEventDown|gpmEventSingle, 0, 0))+	if !ok {+		t.Fatal("button down was ignored")+	}+	if ev.Buttons() != Button1 || ev.Modifiers() != ModShift {+		t.Fatalf("down event = buttons %x modifiers %x", ev.Buttons(), ev.Modifiers())+	}+	if x, y := ev.Position(); x != 2 || y != 3 {+		t.Fatalf("down position = %d,%d", x, y)+	}++	ev, ok = cm.eventFromPacket(gpmTestPacket(gpmButtonRight, 0, 99, -4, gpmEventDown, 0, 0))+	if !ok {+		t.Fatal("button chord was ignored")+	}+	if ev.Buttons() != Button1|Button2 {+		t.Fatalf("chord buttons = %x", ev.Buttons())+	}+	if x, y := ev.Position(); x != 9 || y != 0 {+		t.Fatalf("clipped position = %d,%d", x, y)+	}++	ev, ok = cm.eventFromPacket(gpmTestPacket(gpmButtonLeft, 0, 3, 4, gpmEventUp, 0, 0))+	if !ok {+		t.Fatal("button up was ignored")+	}+	if ev.Buttons() != Button2 {+		t.Fatalf("buttons after release = %x", ev.Buttons())+	}+}++func TestGPMEventFromPacketRespectsMouseFlags(t *testing.T) {+	cm := &gpmConsoleMouse{eventQ: make(chan Event, 1), flags: MouseButtonEvents}+	cm.SetSize(10, 5)+	if _, ok := cm.eventFromPacket(gpmTestPacket(0, 0, 3, 4, gpmEventMove, 0, 0)); ok {+		t.Fatal("motion event passed with MouseButtonEvents only")+	}+	if _, ok := cm.eventFromPacket(gpmTestPacket(gpmButtonLeft, 0, 3, 4, gpmEventDrag, 0, 0)); ok {+		t.Fatal("drag event passed with MouseButtonEvents only")+	}++	cm.flags = MouseDragEvents+	if ev, ok := cm.eventFromPacket(gpmTestPacket(gpmButtonLeft, 0, 3, 4, gpmEventDrag, 0, 0)); !ok || ev.Buttons() != Button1 {+		t.Fatalf("drag event = %#v ok %t", ev, ok)+	}++	cm.flags = MouseMotionEvents+	if ev, ok := cm.eventFromPacket(gpmTestPacket(0, 0, 3, 4, gpmEventMove, 0, 0)); !ok || ev.Buttons() != ButtonNone {+		t.Fatalf("motion event = %#v ok %t", ev, ok)+	}+}++func TestGPMEventFromPacketWheel(t *testing.T) {+	cm := &gpmConsoleMouse{eventQ: make(chan Event, 1), flags: MouseButtonEvents}+	cm.SetSize(10, 5)++	ev, ok := cm.eventFromPacket(gpmTestPacket(gpmButtonUp, gpmModCtrl, 3, 4, gpmEventMove, 0, 0))+	if !ok {+		t.Fatal("wheel up was ignored")+	}+	if ev.Buttons() != WheelUp || ev.Modifiers() != ModCtrl {+		t.Fatalf("wheel up event = buttons %x modifiers %x", ev.Buttons(), ev.Modifiers())+	}++	ev, ok = cm.eventFromPacket(gpmTestPacket(0, 0, 3, 4, 0, 1, -1))+	if !ok {+		t.Fatal("wheel deltas were ignored")+	}+	if ev.Buttons() != WheelRight|WheelDown {+		t.Fatalf("wheel delta event = buttons %x", ev.Buttons())+	}+}++func TestGPMEventFromShortPacketIgnored(t *testing.T) {+	cm := &gpmConsoleMouse{eventQ: make(chan Event, 1), flags: MouseButtonEvents}+	if _, ok := cm.eventFromPacket(make([]byte, gpmEventSize-1)); ok {+		t.Fatal("short packet produced event")+	}+}++func TestGPMStartStopWithPartialFrame(t *testing.T) {+	reader, writer := net.Pipe()+	cm := &gpmConsoleMouse{sock: reader, eventQ: make(chan Event, 1)}+	if err := cm.Start(MouseButtonEvents); err != nil {+		t.Fatalf("Start failed: %v", err)+	}++	if _, err := writer.Write(make([]byte, gpmEventSize/2)); err != nil {+		t.Fatalf("partial write failed: %v", err)+	}+	_ = writer.Close()++	select {+	case <-cm.done:+	case <-time.After(time.Second):+		t.Fatal("GPM loop did not exit after partial frame EOF")+	}+	cm.Stop()+}

tscreen.go

@@ -276,6 +276,7 @@ type tScreen struct { 	wg                 sync.WaitGroup 	eventWg            sync.WaitGroup 	mouseFlags         MouseFlags+	consoleMouse       consoleMouse 	pasteEnabled       bool 	focusEnabled       bool 	setTitle           string@@ -1119,6 +1120,11 @@ func (t *tScreen) EnableMouse(flags ...MouseFlags) { }  func (t *tScreen) enableMouse(f MouseFlags) {+	t.stopConsoleMouse()+	if f != 0 && !t.mouseDisabled && t.startConsoleMouse(f) {+		return+	}+ 	// Rather than using terminfo to find mouse escape sequences, we rely on the fact that 	// pretty much *every* terminal that supports mouse tracking follows the 	// XTerm standards (the modern ones).  It is expected that all terminals understand@@ -1171,6 +1177,31 @@ func (t *tScreen) enableMouse(f MouseFlags) { 	} } +func (t *tScreen) startConsoleMouse(f MouseFlags) bool {+	if t.eventQ == nil || f&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) == 0 {+		return false+	}+	cm, err := newConsoleMouse(t.eventQ)+	if err != nil {+		return false+	}+	cm.SetSize(t.w, t.h)+	if err = cm.Start(f); err != nil {+		cm.Stop()+		return false+	}+	t.consoleMouse = cm+	t.input.SetPixelMouse(false)+	return true+}++func (t *tScreen) stopConsoleMouse() {+	if t.consoleMouse != nil {+		t.consoleMouse.Stop()+		t.consoleMouse = nil+	}+}+ func (t *tScreen) DisableMouse() { 	t.Lock() 	if t.fini {@@ -1275,6 +1306,9 @@ func (t *tScreen) resize() { 	t.h = ws.Height 	t.w = ws.Width 	t.input.SetSize(ws)+	if t.consoleMouse != nil {+		t.consoleMouse.SetSize(ws.Width, ws.Height)+	} }  func (t *tScreen) Colors() int {

tscreen_test.go

@@ -1609,3 +1609,141 @@ func TestScreenFillArea(t *testing.T) { 		t.Errorf("overflowing fill should paint in-bounds cells, got %q", got) 	} }++type fakeConsoleMouse struct {+	startErr error+	started  int+	stopped  int+	flags    MouseFlags+	sizes    []tty.WindowSize+}++func (m *fakeConsoleMouse) Start(flags MouseFlags) error {+	if m.startErr != nil {+		return m.startErr+	}+	m.started+++	m.flags = flags+	return nil+}++func (m *fakeConsoleMouse) Stop() {+	m.stopped+++}++func (m *fakeConsoleMouse) SetSize(w, h int) {+	m.sizes = append(m.sizes, tty.WindowSize{Width: w, Height: h})+}++func withConsoleMouseFactory(t *testing.T, f consoleMouseFactory) {+	t.Helper()+	old := newConsoleMouse+	newConsoleMouse = f+	t.Cleanup(func() { newConsoleMouse = old })+}++func TestNativeConsoleMouseSuppressesVTMouseEnable(t *testing.T) {+	fm := &fakeConsoleMouse{}+	withConsoleMouseFactory(t, func(chan<- Event) (consoleMouse, error) {+		return fm, nil+	})++	tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}+	s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))+	if err != nil {+		t.Fatalf("failed to get screen: %v", err)+	}+	if err := s.Init(); err != nil {+		t.Fatalf("failed to initialize screen: %v", err)+	}+	defer s.Fini()++	tty.writes.Reset()+	s.EnableMouse(MouseButtonEvents)+	if fm.started != 1 {+		t.Fatalf("native console mouse was not started: %d", fm.started)+	}+	if fm.flags != MouseButtonEvents {+		t.Fatalf("native console mouse got flags %x", fm.flags)+	}+	out := tty.Output()+	for _, seq := range []string{vt.PmMouseButton.Enable(), vt.PmMouseDrag.Enable(), vt.PmMouseMotion.Enable(), vt.PmMouseSgr.Enable(), vt.PmMouseSgrPixel.Enable()} {+		if strings.Contains(out, seq) {+			t.Fatalf("native console mouse emitted VT mouse enable sequence %q", seq)+		}+	}+}++func TestNativeConsoleMouseFailureFallsBackToVTMouse(t *testing.T) {+	withConsoleMouseFactory(t, func(chan<- Event) (consoleMouse, error) {+		return nil, errNoConsoleMouse+	})++	tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}+	s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))+	if err != nil {+		t.Fatalf("failed to get screen: %v", err)+	}+	if err := s.Init(); err != nil {+		t.Fatalf("failed to initialize screen: %v", err)+	}+	defer s.Fini()++	tty.writes.Reset()+	s.EnableMouse(MouseButtonEvents)+	out := tty.Output()+	for _, seq := range []string{vt.PmMouseButton.Enable(), vt.PmMouseSgr.Enable()} {+		if !strings.Contains(out, seq) {+			t.Fatalf("native console mouse failure did not fall back to VT sequence %q", seq)+		}+	}+}++func TestNativeConsoleMouseStopsOnDisable(t *testing.T) {+	fm := &fakeConsoleMouse{}+	withConsoleMouseFactory(t, func(chan<- Event) (consoleMouse, error) {+		return fm, nil+	})++	tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}+	s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))+	if err != nil {+		t.Fatalf("failed to get screen: %v", err)+	}+	if err := s.Init(); err != nil {+		t.Fatalf("failed to initialize screen: %v", err)+	}+	defer s.Fini()++	s.EnableMouse(MouseMotionEvents)+	s.DisableMouse()+	if fm.started != 1 {+		t.Fatalf("native console mouse starts = %d", fm.started)+	}+	if fm.stopped != 1 {+		t.Fatalf("native console mouse stops = %d", fm.stopped)+	}+}++func TestMouseDisabledPreventsNativeConsoleMouse(t *testing.T) {+	t.Setenv("TCELL_MOUSE", "disable")+	fm := &fakeConsoleMouse{}+	withConsoleMouseFactory(t, func(chan<- Event) (consoleMouse, error) {+		return fm, nil+	})++	tty := &spyTty{MockTerm: vt.NewMockTerm(vt.MockOptSize{X: 8, Y: 5})}+	s, err := NewTerminfoScreenFromTty(tty, OptNegotiation(false), OptAltScreen(false))+	if err != nil {+		t.Fatalf("failed to get screen: %v", err)+	}+	if err := s.Init(); err != nil {+		t.Fatalf("failed to initialize screen: %v", err)+	}+	defer s.Fini()++	s.EnableMouse()+	if fm.started != 0 {+		t.Fatalf("native console mouse started despite TCELL_MOUSE=disable")+	}+}