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
@@ -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.
@@ -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 {
@@ -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+}
@@ -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+}
@@ -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()+}
@@ -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 {
@@ -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")+ }+}