diff --git a/examples/mouse/main.go b/examples/mouse/main.go index ea5c11d40a..da923ad863 100644 --- a/examples/mouse/main.go +++ b/examples/mouse/main.go @@ -4,21 +4,19 @@ package main // coordinates and events. import ( - "fmt" "log" tea "github.com/charmbracelet/bubbletea" ) func main() { - p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseAllMotion()) + p := tea.NewProgram(model{}, tea.WithMouseAllMotion()) if _, err := p.Run(); err != nil { log.Fatal(err) } } type model struct { - init bool mouseEvent tea.MouseEvent } @@ -34,20 +32,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.MouseMsg: - m.init = true - m.mouseEvent = tea.MouseEvent(msg) + return m, tea.Printf("(X: %d, Y: %d) %s", msg.X, msg.Y, tea.MouseEvent(msg)) } return m, nil } func (m model) View() string { - s := "Do mouse stuff. When you're done press q to quit.\n\n" - - if m.init { - e := m.mouseEvent - s += fmt.Sprintf("(X: %d, Y: %d) %s", e.X, e.Y, e) - } + s := "Do mouse stuff. When you're done press q to quit.\n" return s } diff --git a/examples/simple/testdata/TestApp.golden b/examples/simple/testdata/TestApp.golden index b0b6c3dc97..6b886768a6 100644 --- a/examples/simple/testdata/TestApp.golden +++ b/examples/simple/testdata/TestApp.golden @@ -1,3 +1,3 @@ [?25lHi. This program will exit in 10 seconds. To quit sooner press any key Hi. This program will exit in 9 seconds. To quit sooner press any key. -[?25h[?1002l[?1003l \ No newline at end of file +[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/key.go b/key.go index fe438a627b..f851490a0d 100644 --- a/key.go +++ b/key.go @@ -566,7 +566,7 @@ loop: canHaveMoreData := numBytes == len(buf) var i, w int - for i, w = 0, 0; i < len(b); i += w { + for i, w = 0, 07; i < len(b); i += w { var msg Msg w, msg = detectOneMsg(b[i:], canHaveMoreData) if w == 0 { @@ -591,13 +591,26 @@ loop: } } -var unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) +var ( + unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) + mouseSGRRegex = regexp.MustCompile(`(\d+);(\d+);(\d+)([Mm])`) +) func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) { // Detect mouse events. - const mouseEventLen = 6 - if len(b) >= mouseEventLen && b[0] == '\x1b' && b[1] == '[' && b[2] == 'M' { - return mouseEventLen, MouseMsg(parseX10MouseEvent(b)) + // X10 mouse events have a length of 6 bytes + const mouseEventX10Len = 6 + if len(b) >= mouseEventX10Len && b[0] == '\x1b' && b[1] == '[' { + switch b[2] { + case 'M': + return mouseEventX10Len, MouseMsg(parseX10MouseEvent(b)) + case '<': + if matchIndices := mouseSGRRegex.FindSubmatchIndex(b[3:]); matchIndices != nil { + // SGR mouse events length is the length of the match plus the length of the escape sequence + mouseEventSGRLen := matchIndices[1] + 3 + return mouseEventSGRLen, MouseMsg(parseSGRMouseEvent(b)) + } + } } // Detect escape sequence and control characters other than NUL, diff --git a/key_test.go b/key_test.go index ae8643b903..0b1112a222 100644 --- a/key_test.go +++ b/key_test.go @@ -137,7 +137,12 @@ func TestDetectOneMsg(t *testing.T) { // Mouse event. seqTest{ []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, - MouseMsg{X: 32, Y: 16, Type: MouseWheelUp}, + MouseMsg{X: 32, Y: 16, Type: MouseWheelUp, Button: MouseButtonWheelUp, Action: MouseActionPress}, + }, + // SGR Mouse event. + seqTest{ + []byte("\x1b[<0;33;17M"), + MouseMsg{X: 32, Y: 16, Type: MouseLeft, Button: MouseButtonLeft, Action: MouseActionPress}, }, // Runes. seqTest{ @@ -316,27 +321,33 @@ func TestReadInput(t *testing.T) { []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, []Msg{ MouseMsg{ - X: 32, - Y: 16, - Type: MouseWheelUp, + X: 32, + Y: 16, + Type: MouseWheelUp, + Button: MouseButtonWheelUp, + Action: MouseActionPress, }, }, }, - {"left release", + {"left motion release", []byte{ '\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33), '\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33), }, []Msg{ MouseMsg(MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, + X: 32, + Y: 16, + Type: MouseLeft, + Button: MouseButtonLeft, + Action: MouseActionMotion, }), MouseMsg(MouseEvent{ - X: 64, - Y: 32, - Type: MouseRelease, + X: 64, + Y: 32, + Type: MouseRelease, + Button: MouseButtonNone, + Action: MouseActionRelease, }), }, }, diff --git a/mouse.go b/mouse.go index fc66691cc6..add8d02931 100644 --- a/mouse.go +++ b/mouse.go @@ -1,5 +1,7 @@ package tea +import "strconv" + // MouseMsg contains information about a mouse event and are sent to a programs // update function when mouse activity occurs. Note that the mouse must first // be enabled in order for the mouse events to be received. @@ -13,11 +15,22 @@ func (m MouseMsg) String() string { // MouseEvent represents a mouse event, which could be a click, a scroll wheel // movement, a cursor movement, or a combination. type MouseEvent struct { - X int - Y int + X int + Y int + Shift bool + Alt bool + Ctrl bool + Action MouseAction + Button MouseButton + + // Deprecated: Use MouseAction & MouseButton instead. Type MouseEventType - Alt bool - Ctrl bool +} + +// IsWheel returns true if the mouse event is a wheel event. +func (m MouseEvent) IsWheel() bool { + return m.Button == MouseButtonWheelUp || m.Button == MouseButtonWheelDown || + m.Button == MouseButtonWheelLeft || m.Button == MouseButtonWheelRight } // String returns a string representation of a mouse event. @@ -28,38 +41,170 @@ func (m MouseEvent) String() (s string) { if m.Alt { s += "alt+" } - s += mouseEventTypes[m.Type] + if m.Shift { + s += "shift+" + } + + if m.Button == MouseButtonNone { + if m.Action == MouseActionMotion || m.Action == MouseActionRelease { + s += mouseActions[m.Action] + } else { + s += "unknown" + } + } else if m.IsWheel() { + s += mouseButtons[m.Button] + } else { + btn := mouseButtons[m.Button] + if btn != "" { + s += btn + } + act := mouseActions[m.Action] + if act != "" { + s += " " + act + } + } + return s } +// MouseAction represents the action that occurred during a mouse event. +type MouseAction int + +// Mouse event actions. +const ( + MouseActionPress MouseAction = iota + MouseActionRelease + MouseActionMotion +) + +var mouseActions = map[MouseAction]string{ + MouseActionPress: "press", + MouseActionRelease: "release", + MouseActionMotion: "motion", +} + +// MouseButton represents the button that was pressed during a mouse event. +type MouseButton int + +// Mouse event buttons +// +// This is based on X11 mouse button codes. +// +// 1 = left button +// 2 = middle button (pressing the scroll wheel) +// 3 = right button +// 4 = turn scroll wheel up +// 5 = turn scroll wheel down +// 6 = push scroll wheel left +// 7 = push scroll wheel right +// 8 = 4th button (aka browser backward button) +// 9 = 5th button (aka browser forward button) +// 10 +// 11 +// +// Other buttons are not supported. +const ( + MouseButtonNone MouseButton = iota + MouseButtonLeft + MouseButtonMiddle + MouseButtonRight + MouseButtonWheelUp + MouseButtonWheelDown + MouseButtonWheelLeft + MouseButtonWheelRight + MouseButtonBackward + MouseButtonForward + MouseButton10 + MouseButton11 +) + +var mouseButtons = map[MouseButton]string{ + MouseButtonNone: "none", + MouseButtonLeft: "left", + MouseButtonMiddle: "middle", + MouseButtonRight: "right", + MouseButtonWheelUp: "wheel up", + MouseButtonWheelDown: "wheel down", + MouseButtonWheelLeft: "wheel left", + MouseButtonWheelRight: "wheel right", + MouseButtonBackward: "backward", + MouseButtonForward: "forward", + MouseButton10: "button 10", + MouseButton11: "button 11", +} + // MouseEventType indicates the type of mouse event occurring. +// +// Deprecated: Use MouseAction & MouseButton instead. type MouseEventType int // Mouse event types. +// +// Deprecated: Use MouseAction & MouseButton instead. const ( MouseUnknown MouseEventType = iota MouseLeft MouseRight MouseMiddle - MouseRelease + MouseRelease // mouse button release (X10 only) MouseWheelUp MouseWheelDown + MouseWheelLeft + MouseWheelRight + MouseBackward + MouseForward MouseMotion ) -var mouseEventTypes = map[MouseEventType]string{ - MouseUnknown: "unknown", - MouseLeft: "left", - MouseRight: "right", - MouseMiddle: "middle", - MouseRelease: "release", - MouseWheelUp: "wheel up", - MouseWheelDown: "wheel down", - MouseMotion: "motion", +// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events +// look like: +// +// ESC [ < Cb ; Cx ; Cy (M or m) +// +// where: +// +// Cb is the encoded button code +// Cx is the x-coordinate of the mouse +// Cy is the y-coordinate of the mouse +// M is for button press, m is for button release +// +// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates +func parseSGRMouseEvent(buf []byte) MouseEvent { + str := string(buf[3:]) + matches := mouseSGRRegex.FindStringSubmatch(str) + if len(matches) != 5 { + // Unreachable, we already checked the regex in `detectOneMsg`. + panic("invalid mouse event") + } + + b, _ := strconv.Atoi(matches[1]) + px := matches[2] + py := matches[3] + release := matches[4] == "m" + m := parseMouseButton(b, true) + + // Wheel buttons don't have release events + // Motion can be reported as a release event in some terminals (Windows Terminal) + if m.Action != MouseActionMotion && !m.IsWheel() && release { + m.Action = MouseActionRelease + m.Type = MouseRelease + } + + x, _ := strconv.Atoi(px) + y, _ := strconv.Atoi(py) + + // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). + m.X = x - 1 + m.Y = y - 1 + + return m } +const x10MouseByteOffset = 32 + // Parse X10-encoded mouse events; the simplest kind. The last release of X10 -// was December 1986, by the way. +// was December 1986, by the way. The original X10 mouse protocol limits the Cx +// and Cy coordinates to 223 (=255-032). // // X10 mouse events look like: // @@ -68,9 +213,22 @@ var mouseEventTypes = map[MouseEventType]string{ // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking func parseX10MouseEvent(buf []byte) MouseEvent { v := buf[3:6] + m := parseMouseButton(int(v[0]), false) + + // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). + m.X = int(v[1]) - x10MouseByteOffset - 1 + m.Y = int(v[2]) - x10MouseByteOffset - 1 + + return m +} + +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates +func parseMouseButton(b int, isSGR bool) MouseEvent { var m MouseEvent - const byteOffset = 32 - e := v[0] - byteOffset + e := b + if !isSGR { + e -= x10MouseByteOffset + } const ( bitShift = 0b0000_0100 @@ -78,55 +236,73 @@ func parseX10MouseEvent(buf []byte) MouseEvent { bitCtrl = 0b0001_0000 bitMotion = 0b0010_0000 bitWheel = 0b0100_0000 + bitAdd = 0b1000_0000 // additional buttons 8-11 bitsMask = 0b0000_0011 - - bitsLeft = 0b0000_0000 - bitsMiddle = 0b0000_0001 - bitsRight = 0b0000_0010 - bitsRelease = 0b0000_0011 - - bitsWheelUp = 0b0000_0000 - bitsWheelDown = 0b0000_0001 ) - if e&bitWheel != 0 { - // Check the low two bits. - switch e & bitsMask { - case bitsWheelUp: - m.Type = MouseWheelUp - case bitsWheelDown: - m.Type = MouseWheelDown - } + if e&bitAdd != 0 { + m.Button = MouseButtonBackward + MouseButton(e&bitsMask) + } else if e&bitWheel != 0 { + m.Button = MouseButtonWheelUp + MouseButton(e&bitsMask) } else { - // Check the low two bits. - // We do not separate clicking and dragging. - switch e & bitsMask { - case bitsLeft: - m.Type = MouseLeft - case bitsMiddle: - m.Type = MouseMiddle - case bitsRight: - m.Type = MouseRight - case bitsRelease: - if e&bitMotion != 0 { - m.Type = MouseMotion - } else { - m.Type = MouseRelease - } + m.Button = MouseButtonLeft + MouseButton(e&bitsMask) + // X10 reports a button release as 0b0000_0011 (3) + if e&bitsMask == bitsMask { + m.Action = MouseActionRelease + m.Button = MouseButtonNone } } - if e&bitAlt != 0 { - m.Alt = true - } - if e&bitCtrl != 0 { - m.Ctrl = true + // Motion bit doesn't get reported for wheel events. + if e&bitMotion != 0 && !m.IsWheel() { + m.Action = MouseActionMotion } - // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). - m.X = int(v[1]) - byteOffset - 1 - m.Y = int(v[2]) - byteOffset - 1 + // Modifiers + m.Alt = e&bitAlt != 0 + m.Ctrl = e&bitCtrl != 0 + m.Shift = e&bitShift != 0 + + // backward compatibility + switch { + case m.Button == MouseButtonLeft && m.Action == MouseActionPress: + m.Type = MouseLeft + case m.Button == MouseButtonMiddle && m.Action == MouseActionPress: + m.Type = MouseMiddle + case m.Button == MouseButtonRight && m.Action == MouseActionPress: + m.Type = MouseRight + case m.Button == MouseButtonNone && m.Action == MouseActionRelease: + m.Type = MouseRelease + case m.Button == MouseButtonWheelUp && m.Action == MouseActionPress: + m.Type = MouseWheelUp + case m.Button == MouseButtonWheelDown && m.Action == MouseActionPress: + m.Type = MouseWheelDown + case m.Button == MouseButtonWheelLeft && m.Action == MouseActionPress: + m.Type = MouseWheelLeft + case m.Button == MouseButtonWheelRight && m.Action == MouseActionPress: + m.Type = MouseWheelRight + case m.Button == MouseButtonBackward && m.Action == MouseActionPress: + m.Type = MouseBackward + case m.Button == MouseButtonForward && m.Action == MouseActionPress: + m.Type = MouseForward + case m.Action == MouseActionMotion: + m.Type = MouseMotion + switch m.Button { + case MouseButtonLeft: + m.Type = MouseLeft + case MouseButtonMiddle: + m.Type = MouseMiddle + case MouseButtonRight: + m.Type = MouseRight + case MouseButtonBackward: + m.Type = MouseBackward + case MouseButtonForward: + m.Type = MouseForward + } + default: + m.Type = MouseUnknown + } return m } diff --git a/mouse_test.go b/mouse_test.go index a64a2e302d..30f6ee364b 100644 --- a/mouse_test.go +++ b/mouse_test.go @@ -1,6 +1,9 @@ package tea -import "testing" +import ( + "fmt" + "testing" +) func TestMouseEvent_String(t *testing.T) { tt := []struct { @@ -9,83 +12,186 @@ func TestMouseEvent_String(t *testing.T) { expected string }{ { - name: "unknown", - event: MouseEvent{Type: MouseUnknown}, + name: "unknown", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonNone, + Type: MouseUnknown, + }, expected: "unknown", }, { - name: "left", - event: MouseEvent{Type: MouseLeft}, - expected: "left", + name: "left", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonLeft, + Type: MouseLeft, + }, + expected: "left press", }, { - name: "right", - event: MouseEvent{Type: MouseRight}, - expected: "right", + name: "right", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonRight, + Type: MouseRight, + }, + expected: "right press", }, { - name: "middle", - event: MouseEvent{Type: MouseMiddle}, - expected: "middle", + name: "middle", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonMiddle, + Type: MouseMiddle, + }, + expected: "middle press", }, { - name: "release", - event: MouseEvent{Type: MouseRelease}, + name: "release", + event: MouseEvent{ + Action: MouseActionRelease, + Button: MouseButtonNone, + Type: MouseRelease, + }, expected: "release", }, { - name: "wheel up", - event: MouseEvent{Type: MouseWheelUp}, + name: "wheel up", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonWheelUp, + Type: MouseWheelUp, + }, expected: "wheel up", }, { - name: "wheel down", - event: MouseEvent{Type: MouseWheelDown}, + name: "wheel down", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonWheelDown, + Type: MouseWheelDown, + }, expected: "wheel down", }, { - name: "motion", - event: MouseEvent{Type: MouseMotion}, + name: "wheel left", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonWheelLeft, + Type: MouseWheelLeft, + }, + expected: "wheel left", + }, + { + name: "wheel right", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonWheelRight, + Type: MouseWheelRight, + }, + expected: "wheel right", + }, + { + name: "motion", + event: MouseEvent{ + Action: MouseActionMotion, + Button: MouseButtonNone, + Type: MouseMotion, + }, expected: "motion", }, + { + name: "shift+left release", + event: MouseEvent{ + Type: MouseLeft, + Action: MouseActionRelease, + Button: MouseButtonLeft, + Shift: true, + }, + expected: "shift+left release", + }, + { + name: "shift+left", + event: MouseEvent{ + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Shift: true, + }, + expected: "shift+left press", + }, + { + name: "ctrl+shift+left", + event: MouseEvent{ + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Shift: true, + Ctrl: true, + }, + expected: "ctrl+shift+left press", + }, { name: "alt+left", event: MouseEvent{ - Type: MouseLeft, - Alt: true, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Alt: true, }, - expected: "alt+left", + expected: "alt+left press", }, { name: "ctrl+left", event: MouseEvent{ - Type: MouseLeft, - Ctrl: true, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Ctrl: true, }, - expected: "ctrl+left", + expected: "ctrl+left press", }, { name: "ctrl+alt+left", event: MouseEvent{ - Type: MouseLeft, - Alt: true, - Ctrl: true, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Alt: true, + Ctrl: true, + }, + expected: "ctrl+alt+left press", + }, + { + name: "ctrl+alt+shift+left", + event: MouseEvent{ + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Alt: true, + Ctrl: true, + Shift: true, }, - expected: "ctrl+alt+left", + expected: "ctrl+alt+shift+left press", }, { name: "ignore coordinates", event: MouseEvent{ - X: 100, - Y: 200, - Type: MouseLeft, + X: 100, + Y: 200, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, - expected: "left", + expected: "left press", }, { name: "broken type", event: MouseEvent{ - Type: MouseEventType(-1000), + Type: MouseEventType(-100), + Action: MouseAction(-110), + Button: MouseButton(-120), }, expected: "", }, @@ -127,20 +233,24 @@ func TestParseX10MouseEvent(t *testing.T) { // Position. { name: "zero position", - buf: encode(0b0010_0000, 0, 0), + buf: encode(0b0000_0000, 0, 0), expected: MouseEvent{ - X: 0, - Y: 0, - Type: MouseLeft, + X: 0, + Y: 0, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, }, { name: "max position", - buf: encode(0b0010_0000, 222, 222), // Because 255 (max int8) - 32 - 1. + buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1. expected: MouseEvent{ - X: 222, - Y: 222, - Type: MouseLeft, + X: 222, + Y: 222, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, }, // Simple. @@ -148,173 +258,287 @@ func TestParseX10MouseEvent(t *testing.T) { name: "left", buf: encode(0b0000_0000, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, + X: 32, + Y: 16, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, }, { name: "left in motion", buf: encode(0b0010_0000, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, + X: 32, + Y: 16, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, }, }, { name: "middle", buf: encode(0b0000_0001, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMiddle, + X: 32, + Y: 16, + Type: MouseMiddle, + Action: MouseActionPress, + Button: MouseButtonMiddle, }, }, { name: "middle in motion", buf: encode(0b0010_0001, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMiddle, + X: 32, + Y: 16, + Type: MouseMiddle, + Action: MouseActionMotion, + Button: MouseButtonMiddle, }, }, { name: "right", buf: encode(0b0000_0010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, + X: 32, + Y: 16, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, { name: "right in motion", buf: encode(0b0010_0010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, + X: 32, + Y: 16, + Type: MouseRight, + Action: MouseActionMotion, + Button: MouseButtonRight, }, }, { name: "motion", buf: encode(0b0010_0011, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMotion, + X: 32, + Y: 16, + Type: MouseMotion, + Action: MouseActionMotion, + Button: MouseButtonNone, }, }, { name: "wheel up", buf: encode(0b0100_0000, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelUp, + X: 32, + Y: 16, + Type: MouseWheelUp, + Action: MouseActionPress, + Button: MouseButtonWheelUp, }, }, { name: "wheel down", buf: encode(0b0100_0001, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, + X: 32, + Y: 16, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "wheel left", + buf: encode(0b0100_0010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelLeft, + Action: MouseActionPress, + Button: MouseButtonWheelLeft, + }, + }, + { + name: "wheel right", + buf: encode(0b0100_0011, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelRight, + Action: MouseActionPress, + Button: MouseButtonWheelRight, }, }, { name: "release", buf: encode(0b0000_0011, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRelease, + X: 32, + Y: 16, + Type: MouseRelease, + Action: MouseActionRelease, + Button: MouseButtonNone, + }, + }, + { + name: "backward", + buf: encode(0b1000_0000, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseBackward, + Action: MouseActionPress, + Button: MouseButtonBackward, + }, + }, + { + name: "forward", + buf: encode(0b1000_0001, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseForward, + Action: MouseActionPress, + Button: MouseButtonForward, + }, + }, + { + name: "button 10", + buf: encode(0b1000_0010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseUnknown, + Action: MouseActionPress, + Button: MouseButton10, + }, + }, + { + name: "button 11", + buf: encode(0b1000_0011, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseUnknown, + Action: MouseActionPress, + Button: MouseButton11, }, }, // Combinations. { name: "alt+right", - buf: encode(0b0010_1010, 32, 16), + buf: encode(0b0000_1010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Alt: true, + X: 32, + Y: 16, + Alt: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, { name: "ctrl+right", - buf: encode(0b0011_0010, 32, 16), + buf: encode(0b0001_0010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Ctrl: true, + X: 32, + Y: 16, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, { - name: "ctrl+alt+right", - buf: encode(0b0011_1010, 32, 16), + name: "left in motion", + buf: encode(0b0010_0000, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Alt: true, - Ctrl: true, + X: 32, + Y: 16, + Alt: false, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, }, }, { - name: "alt+wheel down", - buf: encode(0b0100_1001, 32, 16), + name: "alt+right in motion", + buf: encode(0b0010_1010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, - Alt: true, + X: 32, + Y: 16, + Alt: true, + Type: MouseRight, + Action: MouseActionMotion, + Button: MouseButtonRight, }, }, { - name: "ctrl+wheel down", - buf: encode(0b0101_0001, 32, 16), + name: "ctrl+right in motion", + buf: encode(0b0011_0010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, - Ctrl: true, + X: 32, + Y: 16, + Ctrl: true, + Type: MouseRight, + Action: MouseActionMotion, + Button: MouseButtonRight, }, }, { - name: "ctrl+alt+wheel down", - buf: encode(0b0101_1001, 32, 16), + name: "ctrl+alt+right", + buf: encode(0b0001_1010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, - Alt: true, - Ctrl: true, + X: 32, + Y: 16, + Alt: true, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, - // Unknown. { - name: "wheel with unknown bit", - buf: encode(0b0100_0010, 32, 16), + name: "ctrl+wheel up", + buf: encode(0b0101_0000, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseUnknown, + X: 32, + Y: 16, + Ctrl: true, + Type: MouseWheelUp, + Action: MouseActionPress, + Button: MouseButtonWheelUp, }, }, { - name: "unknown with modifier", - buf: encode(0b0100_1010, 32, 16), + name: "alt+wheel down", + buf: encode(0b0100_1001, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseUnknown, - Alt: true, + X: 32, + Y: 16, + Alt: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "ctrl+alt+wheel down", + buf: encode(0b0101_1001, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Ctrl: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, }, }, // Overflow position. @@ -322,9 +546,11 @@ func TestParseX10MouseEvent(t *testing.T) { name: "overflow position", buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1. expected: MouseEvent{ - X: -6, - Y: -33, - Type: MouseLeft, + X: -6, + Y: -33, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, }, }, } @@ -344,3 +570,370 @@ func TestParseX10MouseEvent(t *testing.T) { }) } } + +// func TestParseX10MouseEvent_error(t *testing.T) { +// tt := []struct { +// name string +// buf []byte +// }{ +// { +// name: "empty buf", +// buf: nil, +// }, +// { +// name: "wrong high bit", +// buf: []byte("\x1a[M@A1"), +// }, +// { +// name: "short buf", +// buf: []byte("\x1b[M@A"), +// }, +// { +// name: "long buf", +// buf: []byte("\x1b[M@A11"), +// }, +// } +// +// for i := range tt { +// tc := tt[i] +// +// t.Run(tc.name, func(t *testing.T) { +// _, err := parseX10MouseEvent(tc.buf) +// +// if err == nil { +// t.Fatalf("expected error but got nil") +// } +// }) +// } +// } + +func TestParseSGRMouseEvent(t *testing.T) { + encode := func(b, x, y int, r bool) []byte { + re := 'M' + if r { + re = 'm' + } + return []byte(fmt.Sprintf("\x1b[<%d;%d;%d%c", b, x+1, y+1, re)) + } + + tt := []struct { + name string + buf []byte + expected MouseEvent + }{ + // Position. + { + name: "zero position", + buf: encode(0, 0, 0, false), + expected: MouseEvent{ + X: 0, + Y: 0, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + }, + }, + { + name: "225 position", + buf: encode(0, 225, 225, false), + expected: MouseEvent{ + X: 225, + Y: 225, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + }, + }, + // Simple. + { + name: "left", + buf: encode(0, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + }, + }, + { + name: "left in motion", + buf: encode(32, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, + }, + }, + { + name: "left release", + buf: encode(0, 32, 16, true), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRelease, + Action: MouseActionRelease, + Button: MouseButtonLeft, + }, + }, + { + name: "middle", + buf: encode(1, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMiddle, + Action: MouseActionPress, + Button: MouseButtonMiddle, + }, + }, + { + name: "middle in motion", + buf: encode(33, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMiddle, + Action: MouseActionMotion, + Button: MouseButtonMiddle, + }, + }, + { + name: "middle release", + buf: encode(1, 32, 16, true), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRelease, + Action: MouseActionRelease, + Button: MouseButtonMiddle, + }, + }, + { + name: "right", + buf: encode(2, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, + }, + }, + { + name: "right release", + buf: encode(2, 32, 16, true), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRelease, + Action: MouseActionRelease, + Button: MouseButtonRight, + }, + }, + { + name: "motion", + buf: encode(35, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMotion, + Action: MouseActionMotion, + Button: MouseButtonNone, + }, + }, + { + name: "wheel up", + buf: encode(64, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelUp, + Action: MouseActionPress, + Button: MouseButtonWheelUp, + }, + }, + { + name: "wheel down", + buf: encode(65, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "wheel left", + buf: encode(66, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelLeft, + Action: MouseActionPress, + Button: MouseButtonWheelLeft, + }, + }, + { + name: "wheel right", + buf: encode(67, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelRight, + Action: MouseActionPress, + Button: MouseButtonWheelRight, + }, + }, + { + name: "backward", + buf: encode(128, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseBackward, + Action: MouseActionPress, + Button: MouseButtonBackward, + }, + }, + { + name: "backward in motion", + buf: encode(160, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseBackward, + Action: MouseActionMotion, + Button: MouseButtonBackward, + }, + }, + { + name: "forward", + buf: encode(129, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseForward, + Action: MouseActionPress, + Button: MouseButtonForward, + }, + }, + { + name: "forward in motion", + buf: encode(161, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseForward, + Action: MouseActionMotion, + Button: MouseButtonForward, + }, + }, + // Combinations. + { + name: "alt+right", + buf: encode(10, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, + }, + }, + { + name: "ctrl+right", + buf: encode(18, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, + }, + }, + { + name: "ctrl+alt+right", + buf: encode(26, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, + }, + }, + { + name: "alt+wheel press", + buf: encode(73, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "ctrl+wheel press", + buf: encode(81, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Ctrl: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "ctrl+alt+wheel press", + buf: encode(89, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Ctrl: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "ctrl+alt+shift+wheel press", + buf: encode(93, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Shift: true, + Alt: true, + Ctrl: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + } + + for i := range tt { + tc := tt[i] + + t.Run(tc.name, func(t *testing.T) { + actual := parseSGRMouseEvent(tc.buf) + if tc.expected != actual { + t.Fatalf("expected %#v but got %#v", + tc.expected, + actual, + ) + } + }) + } +} diff --git a/nil_renderer.go b/nil_renderer.go index f5637aa47b..1b1d4409a3 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -17,3 +17,5 @@ func (n nilRenderer) enableMouseCellMotion() {} func (n nilRenderer) disableMouseCellMotion() {} func (n nilRenderer) enableMouseAllMotion() {} func (n nilRenderer) disableMouseAllMotion() {} +func (n nilRenderer) enableMouseSGRMode() {} +func (n nilRenderer) disableMouseSGRMode() {} diff --git a/options.go b/options.go index b9e8104dbc..71e944939a 100644 --- a/options.go +++ b/options.go @@ -108,6 +108,9 @@ func WithAltScreen() ProgramOption { // movement events are also captured if a mouse button is pressed (i.e., drag // events). Cell motion mode is better supported than all motion mode. // +// This will try to enable the mouse in extended mode (SGR), if that is not +// supported by the terminal it will fall back to normal mode (X10). +// // To enable mouse cell motion once the program has already started running use // the EnableMouseCellMotion command. To disable the mouse when the program is // running use the DisableMouse command. @@ -127,6 +130,9 @@ func WithMouseCellMotion() ProgramOption { // wheel, and motion events, which are delivered regardless of whether a mouse // button is pressed, effectively enabling support for hover interactions. // +// This will try to enable the mouse in extended mode (SGR), if that is not +// supported by the terminal it will fall back to normal mode (X10). +// // Many modern terminals support this, but not all. If in doubt, use // EnableMouseCellMotion instead. // diff --git a/renderer.go b/renderer.go index a6f416277f..5a3ee3c48d 100644 --- a/renderer.go +++ b/renderer.go @@ -40,16 +40,22 @@ type renderer interface { // events if a mouse button is pressed (i.e., drag events). enableMouseCellMotion() - // DisableMouseCellMotion disables Mouse Cell Motion tracking. + // disableMouseCellMotion disables Mouse Cell Motion tracking. disableMouseCellMotion() - // EnableMouseAllMotion enables mouse click, release, wheel and motion + // enableMouseAllMotion enables mouse click, release, wheel and motion // events, regardless of whether a mouse button is pressed. Many modern // terminals support this, but not all. enableMouseAllMotion() - // DisableMouseAllMotion disables All Motion mouse tracking. + // disableMouseAllMotion disables All Motion mouse tracking. disableMouseAllMotion() + + // enableMouseSGRMode enables mouse extended mode (SGR). + enableMouseSGRMode() + + // disableMouseSGRMode disables mouse extended mode (SGR). + disableMouseSGRMode() } // repaintMsg forces a full repaint. diff --git a/screen_test.go b/screen_test.go index 2f305e3d15..a6610a647d 100644 --- a/screen_test.go +++ b/screen_test.go @@ -14,42 +14,42 @@ func TestClearMsg(t *testing.T) { { name: "clear_screen", cmds: []Cmd{ClearScreen}, - expected: "\x1b[?25l\x1b[2J\x1b[1;1H\x1b[1;1Hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[2J\x1b[1;1H\x1b[1;1Hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "altscreen", cmds: []Cmd{EnterAltScreen, ExitAltScreen}, - expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "altscreen_autoexit", cmds: []Cmd{EnterAltScreen}, - expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25lsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1049l\x1b[?25h", + expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25lsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h", }, { name: "mouse_cellmotion", cmds: []Cmd{EnableMouseCellMotion}, - expected: "\x1b[?25l\x1b[?1002hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?1002h\x1b[?1006hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "mouse_allmotion", cmds: []Cmd{EnableMouseAllMotion}, - expected: "\x1b[?25l\x1b[?1003hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?1003h\x1b[?1006hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "mouse_disable", cmds: []Cmd{EnableMouseAllMotion, DisableMouse}, - expected: "\x1b[?25l\x1b[?1003h\x1b[?1002l\x1b[?1003lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "cursor_hide", cmds: []Cmd{HideCursor}, - expected: "\x1b[?25l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "cursor_hideshow", cmds: []Cmd{HideCursor, ShowCursor}, - expected: "\x1b[?25l\x1b[?25l\x1b[?25hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?25l\x1b[?25hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, } diff --git a/standard_renderer.go b/standard_renderer.go index 0f282d8bb9..1573a1c278 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -396,6 +396,20 @@ func (r *standardRenderer) disableMouseAllMotion() { r.out.DisableMouseAllMotion() } +func (r *standardRenderer) enableMouseSGRMode() { + r.mtx.Lock() + defer r.mtx.Unlock() + + r.out.EnableMouseExtendedMode() +} + +func (r *standardRenderer) disableMouseSGRMode() { + r.mtx.Lock() + defer r.mtx.Unlock() + + r.out.DisableMouseExtendedMode() +} + // setIgnoredLines specifies lines not to be touched by the standard Bubble Tea // renderer. func (r *standardRenderer) setIgnoredLines(from int, to int) { diff --git a/tea.go b/tea.go index 74e83e35d3..606db3b48f 100644 --- a/tea.go +++ b/tea.go @@ -301,6 +301,12 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { return ch } +func (p *Program) disableMouse() { + p.renderer.disableMouseCellMotion() + p.renderer.disableMouseAllMotion() + p.renderer.disableMouseSGRMode() +} + // eventLoop is the central message loop. It receives and handles the default // Bubble Tea messages, update the model and triggers redraws. func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { @@ -335,15 +341,18 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { case exitAltScreenMsg: p.renderer.exitAltScreen() - case enableMouseCellMotionMsg: - p.renderer.enableMouseCellMotion() - - case enableMouseAllMotionMsg: - p.renderer.enableMouseAllMotion() + case enableMouseCellMotionMsg, enableMouseAllMotionMsg: + switch msg.(type) { + case enableMouseCellMotionMsg: + p.renderer.enableMouseCellMotion() + case enableMouseAllMotionMsg: + p.renderer.enableMouseAllMotion() + } + // mouse mode (1006) is a no-op if the terminal doesn't support it. + p.renderer.enableMouseSGRMode() case disableMouseMsg: - p.renderer.disableMouseCellMotion() - p.renderer.disableMouseAllMotion() + p.disableMouse() case showCursorMsg: p.renderer.showCursor() @@ -486,8 +495,10 @@ func (p *Program) Run() (Model, error) { } if p.startupOptions&withMouseCellMotion != 0 { p.renderer.enableMouseCellMotion() + p.renderer.enableMouseSGRMode() } else if p.startupOptions&withMouseAllMotion != 0 { p.renderer.enableMouseAllMotion() + p.renderer.enableMouseSGRMode() } // Initialize the program. diff --git a/tty.go b/tty.go index bd9717efbf..01f084d438 100644 --- a/tty.go +++ b/tty.go @@ -35,8 +35,7 @@ func (p *Program) initTerminal() error { func (p *Program) restoreTerminalState() error { if p.renderer != nil { p.renderer.showCursor() - p.renderer.disableMouseCellMotion() - p.renderer.disableMouseAllMotion() + p.disableMouse() if p.renderer.altScreen() { p.renderer.exitAltScreen()