Repository: charmbracelet/bubbletea
Pull request #568 (2022-10-21 15:02:19)

fix(key),test: simplify the input analysis code
This PR contains only the input simplification part of #511.

cc @muesli 

knz: @muesli this is ready again.

Patch:
diff --git a/key.go b/key.go
index c2e5e3a..bbd533c 100644
--- a/key.go
+++ b/key.go
@@ -1,8 +1,9 @@
 package tea
 
 import (
-	"errors"
+	"fmt"
 	"io"
+	"regexp"
 	"unicode/utf8"
 
 	"github.com/mattn/go-localereader"
@@ -338,85 +339,73 @@ var keyNames = map[KeyType]string{
 // Sequence mappings.
 var sequences = map[string]Key{
 	// Arrow keys
-	"\x1b[A":     {Type: KeyUp},
-	"\x1b[B":     {Type: KeyDown},
-	"\x1b[C":     {Type: KeyRight},
-	"\x1b[D":     {Type: KeyLeft},
-	"\x1b[1;2A":  {Type: KeyShiftUp},
-	"\x1b[1;2B":  {Type: KeyShiftDown},
-	"\x1b[1;2C":  {Type: KeyShiftRight},
-	"\x1b[1;2D":  {Type: KeyShiftLeft},
-	"\x1b[OA":    {Type: KeyShiftUp},    // DECCKM
-	"\x1b[OB":    {Type: KeyShiftDown},  // DECCKM
-	"\x1b[OC":    {Type: KeyShiftRight}, // DECCKM
-	"\x1b[OD":    {Type: KeyShiftLeft},  // DECCKM
-	"\x1b[a":     {Type: KeyShiftUp},    // urxvt
-	"\x1b[b":     {Type: KeyShiftDown},  // urxvt
-	"\x1b[c":     {Type: KeyShiftRight}, // urxvt
-	"\x1b[d":     {Type: KeyShiftLeft},  // urxvt
-	"\x1b[1;3A":  {Type: KeyUp, Alt: true},
-	"\x1b[1;3B":  {Type: KeyDown, Alt: true},
-	"\x1b[1;3C":  {Type: KeyRight, Alt: true},
-	"\x1b[1;3D":  {Type: KeyLeft, Alt: true},
-	"\x1b\x1b[A": {Type: KeyUp, Alt: true},    // urxvt
-	"\x1b\x1b[B": {Type: KeyDown, Alt: true},  // urxvt
-	"\x1b\x1b[C": {Type: KeyRight, Alt: true}, // urxvt
-	"\x1b\x1b[D": {Type: KeyLeft, Alt: true},  // urxvt
-	"\x1b[1;4A":  {Type: KeyShiftUp, Alt: true},
-	"\x1b[1;4B":  {Type: KeyShiftDown, Alt: true},
-	"\x1b[1;4C":  {Type: KeyShiftRight, Alt: true},
-	"\x1b[1;4D":  {Type: KeyShiftLeft, Alt: true},
-	"\x1b\x1b[a": {Type: KeyShiftUp, Alt: true},    // urxvt
-	"\x1b\x1b[b": {Type: KeyShiftDown, Alt: true},  // urxvt
-	"\x1b\x1b[c": {Type: KeyShiftRight, Alt: true}, // urxvt
-	"\x1b\x1b[d": {Type: KeyShiftLeft, Alt: true},  // urxvt
-	"\x1b[1;5A":  {Type: KeyCtrlUp},
-	"\x1b[1;5B":  {Type: KeyCtrlDown},
-	"\x1b[1;5C":  {Type: KeyCtrlRight},
-	"\x1b[1;5D":  {Type: KeyCtrlLeft},
-	"\x1b[Oa":    {Type: KeyCtrlUp, Alt: true},    // urxvt
-	"\x1b[Ob":    {Type: KeyCtrlDown, Alt: true},  // urxvt
-	"\x1b[Oc":    {Type: KeyCtrlRight, Alt: true}, // urxvt
-	"\x1b[Od":    {Type: KeyCtrlLeft, Alt: true},  // urxvt
-	"\x1b[1;6A":  {Type: KeyCtrlShiftUp},
-	"\x1b[1;6B":  {Type: KeyCtrlShiftDown},
-	"\x1b[1;6C":  {Type: KeyCtrlShiftRight},
-	"\x1b[1;6D":  {Type: KeyCtrlShiftLeft},
-	"\x1b[1;7A":  {Type: KeyCtrlUp, Alt: true},
-	"\x1b[1;7B":  {Type: KeyCtrlDown, Alt: true},
-	"\x1b[1;7C":  {Type: KeyCtrlRight, Alt: true},
-	"\x1b[1;7D":  {Type: KeyCtrlLeft, Alt: true},
-	"\x1b[1;8A":  {Type: KeyCtrlShiftUp, Alt: true},
-	"\x1b[1;8B":  {Type: KeyCtrlShiftDown, Alt: true},
-	"\x1b[1;8C":  {Type: KeyCtrlShiftRight, Alt: true},
-	"\x1b[1;8D":  {Type: KeyCtrlShiftLeft, Alt: true},
+	"\x1b[A":    {Type: KeyUp},
+	"\x1b[B":    {Type: KeyDown},
+	"\x1b[C":    {Type: KeyRight},
+	"\x1b[D":    {Type: KeyLeft},
+	"\x1b[1;2A": {Type: KeyShiftUp},
+	"\x1b[1;2B": {Type: KeyShiftDown},
+	"\x1b[1;2C": {Type: KeyShiftRight},
+	"\x1b[1;2D": {Type: KeyShiftLeft},
+	"\x1b[OA":   {Type: KeyShiftUp},    // DECCKM
+	"\x1b[OB":   {Type: KeyShiftDown},  // DECCKM
+	"\x1b[OC":   {Type: KeyShiftRight}, // DECCKM
+	"\x1b[OD":   {Type: KeyShiftLeft},  // DECCKM
+	"\x1b[a":    {Type: KeyShiftUp},    // urxvt
+	"\x1b[b":    {Type: KeyShiftDown},  // urxvt
+	"\x1b[c":    {Type: KeyShiftRight}, // urxvt
+	"\x1b[d":    {Type: KeyShiftLeft},  // urxvt
+	"\x1b[1;3A": {Type: KeyUp, Alt: true},
+	"\x1b[1;3B": {Type: KeyDown, Alt: true},
+	"\x1b[1;3C": {Type: KeyRight, Alt: true},
+	"\x1b[1;3D": {Type: KeyLeft, Alt: true},
+
+	"\x1b[1;4A": {Type: KeyShiftUp, Alt: true},
+	"\x1b[1;4B": {Type: KeyShiftDown, Alt: true},
+	"\x1b[1;4C": {Type: KeyShiftRight, Alt: true},
+	"\x1b[1;4D": {Type: KeyShiftLeft, Alt: true},
+
+	"\x1b[1;5A": {Type: KeyCtrlUp},
+	"\x1b[1;5B": {Type: KeyCtrlDown},
+	"\x1b[1;5C": {Type: KeyCtrlRight},
+	"\x1b[1;5D": {Type: KeyCtrlLeft},
+	"\x1b[Oa":   {Type: KeyCtrlUp, Alt: true},    // urxvt
+	"\x1b[Ob":   {Type: KeyCtrlDown, Alt: true},  // urxvt
+	"\x1b[Oc":   {Type: KeyCtrlRight, Alt: true}, // urxvt
+	"\x1b[Od":   {Type: KeyCtrlLeft, Alt: true},  // urxvt
+	"\x1b[1;6A": {Type: KeyCtrlShiftUp},
+	"\x1b[1;6B": {Type: KeyCtrlShiftDown},
+	"\x1b[1;6C": {Type: KeyCtrlShiftRight},
+	"\x1b[1;6D": {Type: KeyCtrlShiftLeft},
+	"\x1b[1;7A": {Type: KeyCtrlUp, Alt: true},
+	"\x1b[1;7B": {Type: KeyCtrlDown, Alt: true},
+	"\x1b[1;7C": {Type: KeyCtrlRight, Alt: true},
+	"\x1b[1;7D": {Type: KeyCtrlLeft, Alt: true},
+	"\x1b[1;8A": {Type: KeyCtrlShiftUp, Alt: true},
+	"\x1b[1;8B": {Type: KeyCtrlShiftDown, Alt: true},
+	"\x1b[1;8C": {Type: KeyCtrlShiftRight, Alt: true},
+	"\x1b[1;8D": {Type: KeyCtrlShiftLeft, Alt: true},
 
 	// Miscellaneous keys
 	"\x1b[Z": {Type: KeyShiftTab},
 
-	"\x1b[2~":     {Type: KeyInsert},
-	"\x1b[3;2~":   {Type: KeyInsert, Alt: true},
-	"\x1b\x1b[2~": {Type: KeyInsert, Alt: true}, // urxvt
-
-	"\x1b[3~":     {Type: KeyDelete},
-	"\x1b[3;3~":   {Type: KeyDelete, Alt: true},
-	"\x1b\x1b[3~": {Type: KeyDelete, Alt: true}, // urxvt
-
-	"\x1b[5~":     {Type: KeyPgUp},
-	"\x1b[5;3~":   {Type: KeyPgUp, Alt: true},
-	"\x1b\x1b[5~": {Type: KeyPgUp, Alt: true}, // urxvt
-	"\x1b[5;5~":   {Type: KeyCtrlPgUp},
-	"\x1b[5^":     {Type: KeyCtrlPgUp}, // urxvt
-	"\x1b[5;7~":   {Type: KeyCtrlPgUp, Alt: true},
-	"\x1b\x1b[5^": {Type: KeyCtrlPgUp, Alt: true}, // urxvt
-
-	"\x1b[6~":     {Type: KeyPgDown},
-	"\x1b[6;3~":   {Type: KeyPgDown, Alt: true},
-	"\x1b\x1b[6~": {Type: KeyPgDown, Alt: true}, // urxvt
-	"\x1b[6;5~":   {Type: KeyCtrlPgDown},
-	"\x1b[6^":     {Type: KeyCtrlPgDown}, // urxvt
-	"\x1b[6;7~":   {Type: KeyCtrlPgDown, Alt: true},
-	"\x1b\x1b[6^": {Type: KeyCtrlPgDown, Alt: true}, // urxvt
+	"\x1b[2~":   {Type: KeyInsert},
+	"\x1b[3;2~": {Type: KeyInsert, Alt: true},
+
+	"\x1b[3~":   {Type: KeyDelete},
+	"\x1b[3;3~": {Type: KeyDelete, Alt: true},
+
+	"\x1b[5~":   {Type: KeyPgUp},
+	"\x1b[5;3~": {Type: KeyPgUp, Alt: true},
+	"\x1b[5;5~": {Type: KeyCtrlPgUp},
+	"\x1b[5^":   {Type: KeyCtrlPgUp}, // urxvt
+	"\x1b[5;7~": {Type: KeyCtrlPgUp, Alt: true},
+
+	"\x1b[6~":   {Type: KeyPgDown},
+	"\x1b[6;3~": {Type: KeyPgDown, Alt: true},
+	"\x1b[6;5~": {Type: KeyCtrlPgDown},
+	"\x1b[6^":   {Type: KeyCtrlPgDown}, // urxvt
+	"\x1b[6;7~": {Type: KeyCtrlPgDown, Alt: true},
 
 	"\x1b[1~":   {Type: KeyHome},
 	"\x1b[H":    {Type: KeyHome},                     // xterm, lxterm
@@ -438,23 +427,15 @@ var sequences = map[string]Key{
 	"\x1b[1;6F": {Type: KeyCtrlShiftEnd},            // xterm, lxterm
 	"\x1b[1;8F": {Type: KeyCtrlShiftEnd, Alt: true}, // xterm, lxterm
 
-	"\x1b[7~":     {Type: KeyHome},                     // urxvt
-	"\x1b\x1b[7~": {Type: KeyHome, Alt: true},          // urxvt
-	"\x1b[7^":     {Type: KeyCtrlHome},                 // urxvt
-	"\x1b\x1b[7^": {Type: KeyCtrlHome, Alt: true},      // urxvt
-	"\x1b[7$":     {Type: KeyShiftHome},                // urxvt
-	"\x1b\x1b[7$": {Type: KeyShiftHome, Alt: true},     // urxvt
-	"\x1b[7@":     {Type: KeyCtrlShiftHome},            // urxvt
-	"\x1b\x1b[7@": {Type: KeyCtrlShiftHome, Alt: true}, // urxvt
-
-	"\x1b[8~":     {Type: KeyEnd},                     // urxvt
-	"\x1b\x1b[8~": {Type: KeyEnd, Alt: true},          // urxvt
-	"\x1b[8^":     {Type: KeyCtrlEnd},                 // urxvt
-	"\x1b\x1b[8^": {Type: KeyCtrlEnd, Alt: true},      // urxvt
-	"\x1b[8$":     {Type: KeyShiftEnd},                // urxvt
-	"\x1b\x1b[8$": {Type: KeyShiftEnd, Alt: true},     // urxvt
-	"\x1b[8@":     {Type: KeyCtrlShiftEnd},            // urxvt
-	"\x1b\x1b[8@": {Type: KeyCtrlShiftEnd, Alt: true}, // urxvt
+	"\x1b[7~": {Type: KeyHome},          // urxvt
+	"\x1b[7^": {Type: KeyCtrlHome},      // urxvt
+	"\x1b[7$": {Type: KeyShiftHome},     // urxvt
+	"\x1b[7@": {Type: KeyCtrlShiftHome}, // urxvt
+
+	"\x1b[8~": {Type: KeyEnd},          // urxvt
+	"\x1b[8^": {Type: KeyCtrlEnd},      // urxvt
+	"\x1b[8$": {Type: KeyShiftEnd},     // urxvt
+	"\x1b[8@": {Type: KeyCtrlShiftEnd}, // urxvt
 
 	// Function keys, Linux console
 	"\x1b[[A": {Type: KeyF1}, // linux console
@@ -479,29 +460,16 @@ var sequences = map[string]Key{
 	"\x1b[13~": {Type: KeyF3}, // urxvt
 	"\x1b[14~": {Type: KeyF4}, // urxvt
 
-	"\x1b\x1b[11~": {Type: KeyF1, Alt: true}, // urxvt
-	"\x1b\x1b[12~": {Type: KeyF2, Alt: true}, // urxvt
-	"\x1b\x1b[13~": {Type: KeyF3, Alt: true}, // urxvt
-	"\x1b\x1b[14~": {Type: KeyF4, Alt: true}, // urxvt
-
 	"\x1b[15~": {Type: KeyF5}, // vt100, xterm, also urxvt
 
 	"\x1b[15;3~": {Type: KeyF5, Alt: true}, // vt100, xterm, also urxvt
 
-	"\x1b\x1b[15~": {Type: KeyF5, Alt: true}, // urxvt
-
 	"\x1b[17~": {Type: KeyF6},  // vt100, xterm, also urxvt
 	"\x1b[18~": {Type: KeyF7},  // vt100, xterm, also urxvt
 	"\x1b[19~": {Type: KeyF8},  // vt100, xterm, also urxvt
 	"\x1b[20~": {Type: KeyF9},  // vt100, xterm, also urxvt
 	"\x1b[21~": {Type: KeyF10}, // vt100, xterm, also urxvt
 
-	"\x1b\x1b[17~": {Type: KeyF6, Alt: true},  // urxvt
-	"\x1b\x1b[18~": {Type: KeyF7, Alt: true},  // urxvt
-	"\x1b\x1b[19~": {Type: KeyF8, Alt: true},  // urxvt
-	"\x1b\x1b[20~": {Type: KeyF9, Alt: true},  // urxvt
-	"\x1b\x1b[21~": {Type: KeyF10, Alt: true}, // urxvt
-
 	"\x1b[17;3~": {Type: KeyF6, Alt: true},  // vt100, xterm
 	"\x1b[18;3~": {Type: KeyF7, Alt: true},  // vt100, xterm
 	"\x1b[19;3~": {Type: KeyF8, Alt: true},  // vt100, xterm
@@ -514,9 +482,6 @@ var sequences = map[string]Key{
 	"\x1b[23;3~": {Type: KeyF11, Alt: true}, // vt100, xterm
 	"\x1b[24;3~": {Type: KeyF12, Alt: true}, // vt100, xterm
 
-	"\x1b\x1b[23~": {Type: KeyF11, Alt: true}, // urxvt
-	"\x1b\x1b[24~": {Type: KeyF12, Alt: true}, // urxvt
-
 	"\x1b[1;2P": {Type: KeyF13},
 	"\x1b[1;2Q": {Type: KeyF14},
 
@@ -526,9 +491,6 @@ var sequences = map[string]Key{
 	"\x1b[25;3~": {Type: KeyF13, Alt: true}, // vt100, xterm
 	"\x1b[26;3~": {Type: KeyF14, Alt: true}, // vt100, xterm
 
-	"\x1b\x1b[25~": {Type: KeyF13, Alt: true}, // urxvt
-	"\x1b\x1b[26~": {Type: KeyF14, Alt: true}, // urxvt
-
 	"\x1b[1;2R": {Type: KeyF15},
 	"\x1b[1;2S": {Type: KeyF16},
 
@@ -538,9 +500,6 @@ var sequences = map[string]Key{
 	"\x1b[28;3~": {Type: KeyF15, Alt: true}, // vt100, xterm
 	"\x1b[29;3~": {Type: KeyF16, Alt: true}, // vt100, xterm
 
-	"\x1b\x1b[28~": {Type: KeyF15, Alt: true}, // urxvt
-	"\x1b\x1b[29~": {Type: KeyF16, Alt: true}, // urxvt
-
 	"\x1b[15;2~": {Type: KeyF17},
 	"\x1b[17;2~": {Type: KeyF18},
 	"\x1b[18;2~": {Type: KeyF19},
@@ -551,11 +510,6 @@ var sequences = map[string]Key{
 	"\x1b[33~": {Type: KeyF19},
 	"\x1b[34~": {Type: KeyF20},
 
-	"\x1b\x1b[31~": {Type: KeyF17, Alt: true}, // urxvt
-	"\x1b\x1b[32~": {Type: KeyF18, Alt: true}, // urxvt
-	"\x1b\x1b[33~": {Type: KeyF19, Alt: true}, // urxvt
-	"\x1b\x1b[34~": {Type: KeyF20, Alt: true}, // urxvt
-
 	// Powershell sequences.
 	"\x1bOA": {Type: KeyUp, Alt: false},
 	"\x1bOB": {Type: KeyDown, Alt: false},
@@ -563,102 +517,118 @@ var sequences = map[string]Key{
 	"\x1bOD": {Type: KeyLeft, Alt: false},
 }
 
+// unknownInputByteMsg is reported by the input reader when an invalid
+// utf-8 byte is detected on the input. Currently, it is not handled
+// further by bubbletea. However, having this event makes it possible
+// to troubleshoot invalid inputs.
+type unknownInputByteMsg byte
+
+func (u unknownInputByteMsg) String() string {
+	return fmt.Sprintf("?%#02x?", int(u))
+}
+
+// unknownCSISequenceMsg is reported by the input reader when an
+// unrecognized CSI sequence is detected on the input. Currently, it
+// is not handled further by bubbletea. However, having this event
+// makes it possible to troubleshoot invalid inputs.
+type unknownCSISequenceMsg []byte
+
+func (u unknownCSISequenceMsg) String() string {
+	return fmt.Sprintf("?CSI%+v?", []byte(u)[2:])
+}
+
+var spaceRunes = []rune{' '}
+
 // readInputs reads keypress and mouse inputs from a TTY and returns messages
 // containing information about the key or mouse events accordingly.
 func readInputs(input io.Reader) ([]Msg, error) {
 	var buf [256]byte
 
+	input = localereader.NewReader(input)
+
 	// Read and block
 	numBytes, err := input.Read(buf[:])
 	if err != nil {
 		return nil, err
 	}
 	b := buf[:numBytes]
-	b, err = localereader.UTF8(b)
-	if err != nil {
-		return nil, err
-	}
 
-	// Check if it's a mouse event. For now we're parsing X10-type mouse events
-	// only.
-	mouseEvent, err := parseX10MouseEvents(b)
-	if err == nil {
-		var m []Msg
-		for _, v := range mouseEvent {
-			m = append(m, MouseMsg(v))
-		}
-		return m, nil
+	var msgs []Msg
+	for i, w := 0, 0; i < len(b); i += w {
+		var msg Msg
+		w, msg = detectOneMsg(b[i:])
+		msgs = append(msgs, msg)
 	}
+	return msgs, nil
+}
 
-	var runeSets [][]rune
-	var runes []rune
+var unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`)
 
-	// Translate input into runes. In most cases we'll receive exactly one
-	// rune, but there are cases, particularly when an input method editor is
-	// used, where we can receive multiple runes at once.
-	for i, w := 0, 0; i < len(b); i += w {
-		r, width := utf8.DecodeRune(b[i:])
-		if r == utf8.RuneError {
-			return nil, errors.New("could not decode rune")
-		}
+func detectOneMsg(b []byte) (w int, msg Msg) {
+	// Detect mouse events.
+	if len(b) >= 6 && b[0] == '\x1b' && b[1] == '[' && b[2] == 'M' {
+		return 6, MouseMsg(parseX10MouseEvent(b))
+	}
 
-		if r == '\x1b' && len(runes) > 1 {
-			// a new key sequence has started
-			runeSets = append(runeSets, runes)
-			runes = []rune{}
-		}
+	// Detect escape sequence and control characters other than NUL,
+	// possibly with an escape character in front to mark the Alt
+	// modifier.
+	var foundSeq bool
+	foundSeq, w, msg = detectSequence(b)
+	if foundSeq {
+		return
+	}
 
-		runes = append(runes, r)
-		w = width
+	// No non-NUL control character or escape sequence.
+	// If we are seeing at least an escape character, remember it for later below.
+	alt := false
+	i := 0
+	if b[0] == '\x1b' {
+		alt = true
+		i++
 	}
-	// add the final set of runes we decoded
-	runeSets = append(runeSets, runes)
 
-	if len(runeSets) == 0 {
-		return nil, errors.New("received 0 runes from input")
+	// Are we seeing a standalone NUL? This is not handled by detectSequence().
+	if i < len(b) && b[i] == 0 {
+		return i + 1, KeyMsg{Type: keyNUL, Alt: alt}
 	}
 
-	var msgs []Msg
-	for _, runes := range runeSets {
-		// Is it a sequence, like an arrow key?
-		if k, ok := sequences[string(runes)]; ok {
-			msgs = append(msgs, KeyMsg(k))
-			continue
+	// Find the longest sequence of runes that are not control
+	// characters from this point.
+	var runes []rune
+	for rw := 0; i < len(b); i += rw {
+		var r rune
+		r, rw = utf8.DecodeRune(b[i:])
+		if r == utf8.RuneError || r <= rune(keyUS) || r == rune(keyDEL) || r == ' ' {
+			// Rune errors are handled below; control characters and spaces will
+			// be handled by detectSequence in the next call to detectOneMsg.
+			break
 		}
-
-		// Is this an unrecognized CSI sequence? If so, ignore it.
-		if len(runes) > 2 && runes[0] == 0x1b && (runes[1] == '[' ||
-			(len(runes) > 3 && runes[1] == 0x1b && runes[2] == '[')) {
-			continue
+		runes = append(runes, r)
+		if alt {
+			// We only support a single rune after an escape alt modifier.
+			i += rw
+			break
 		}
-
-		// Is the alt key pressed? If so, the buffer will be prefixed with an
-		// escape.
-		alt := false
-		if len(runes) > 1 && runes[0] == 0x1b {
-			alt = true
-			runes = runes[1:]
+	}
+	// If we found at least one rune, we report the bunch of them as
+	// a single KeyRunes or KeySpace event.
+	if len(runes) > 0 {
+		k := Key{Type: KeyRunes, Runes: runes, Alt: alt}
+		if len(runes) == 1 && runes[0] == ' ' {
+			k.Type = KeySpace
 		}
+		return i, KeyMsg(k)
+	}
 
-		for _, v := range runes {
-			// Is the first rune a control character?
-			r := KeyType(v)
-			if r <= keyUS || r == keyDEL {
-				msgs = append(msgs, KeyMsg(Key{Type: r, Alt: alt}))
-				continue
-			}
-
-			// If it's a space, override the type with KeySpace (but still include
-			// the rune).
-			if r == ' ' {
-				msgs = append(msgs, KeyMsg(Key{Type: KeySpace, Runes: []rune{v}, Alt: alt}))
-				continue
-			}
-
-			// Welp, just regular, ol' runes.
-			msgs = append(msgs, KeyMsg(Key{Type: KeyRunes, Runes: []rune{v}, Alt: alt}))
-		}
+	// We didn't find an escape sequence, nor a valid rune. Was this a
+	// lone escape character at the end of the input?
+	if alt && len(b) == 1 {
+		return 1, KeyMsg(Key{Type: KeyEscape})
 	}
 
-	return msgs, nil
+	// The character at the current position is neither an escape
+	// sequence, a valid rune start or a sole escape character. Report
+	// it as an invalid byte.
+	return 1, unknownInputByteMsg(b[0])
 }
diff --git a/key_sequences.go b/key_sequences.go
new file mode 100644
index 0000000..cc200f8
--- /dev/null
+++ b/key_sequences.go
@@ -0,0 +1,71 @@
+package tea
+
+import "sort"
+
+// extSequences is used by the map-based algorithm below. It contains
+// the sequences plus their alternatives with an escape character
+// prefixed, plus the control chars, plus the space.
+// It does not contain the NUL character, which is handled specially
+// by detectOneMsg.
+var extSequences = func() map[string]Key {
+	s := map[string]Key{}
+	for seq, key := range sequences {
+		key := key
+		s[seq] = key
+		if !key.Alt {
+			key.Alt = true
+			s["\x1b"+seq] = key
+		}
+	}
+	for i := keyNUL + 1; i <= keyDEL; i++ {
+		if i == keyESC {
+			continue
+		}
+		s[string([]byte{byte(i)})] = Key{Type: i}
+		s[string([]byte{'\x1b', byte(i)})] = Key{Type: i, Alt: true}
+		if i == keyUS {
+			i = keyDEL - 1
+		}
+	}
+	s[" "] = Key{Type: KeySpace, Runes: spaceRunes}
+	s["\x1b "] = Key{Type: KeySpace, Alt: true, Runes: spaceRunes}
+	s["\x1b\x1b"] = Key{Type: KeyEscape, Alt: true}
+	return s
+}()
+
+// seqLengths is the sizes of valid sequences, starting with the
+// largest size.
+var seqLengths = func() []int {
+	sizes := map[int]struct{}{}
+	for seq := range extSequences {
+		sizes[len(seq)] = struct{}{}
+	}
+	lsizes := make([]int, 0, len(sizes))
+	for sz := range sizes {
+		lsizes = append(lsizes, sz)
+	}
+	sort.Slice(lsizes, func(i, j int) bool { return lsizes[i] > lsizes[j] })
+	return lsizes
+}()
+
+// detectSequence uses a longest prefix match over the input
+// sequence and a hash map.
+func detectSequence(input []byte) (hasSeq bool, width int, msg Msg) {
+	seqs := extSequences
+	for _, sz := range seqLengths {
+		if sz > len(input) {
+			continue
+		}
+		prefix := input[:sz]
+		key, ok := seqs[string(prefix)]
+		if ok {
+			return true, sz, KeyMsg(key)
+		}
+	}
+	// Is this an unknown CSI sequence?
+	if loc := unknownCSIRe.FindIndex(input); loc != nil {
+		return true, loc[1], unknownCSISequenceMsg(input[:loc[1]])
+	}
+
+	return false, 0, nil
+}
diff --git a/mouse.go b/mouse.go
index f918d20..fc66691 100644
--- a/mouse.go
+++ b/mouse.go
@@ -1,15 +1,15 @@
 package tea
 
-import (
-	"bytes"
-	"errors"
-)
-
-// MouseMsg contains information about a mouse event and is sent to a program's
+// 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.
 type MouseMsg MouseEvent
 
+// String returns a string representation of a mouse event.
+func (m MouseMsg) String() string {
+	return MouseEvent(m).String()
+}
+
 // MouseEvent represents a mouse event, which could be a click, a scroll wheel
 // movement, a cursor movement, or a combination.
 type MouseEvent struct {
@@ -66,84 +66,67 @@ var mouseEventTypes = map[MouseEventType]string{
 //	ESC [M Cb Cx Cy
 //
 // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
-func parseX10MouseEvents(buf []byte) ([]MouseEvent, error) {
-	var r []MouseEvent
-
-	seq := []byte("\x1b[M")
-	if !bytes.Contains(buf, seq) {
-		return r, errors.New("not an X10 mouse event")
-	}
-
-	for _, v := range bytes.Split(buf, seq) {
-		if len(v) == 0 {
-			continue
-		}
-		if len(v) != 3 {
-			return r, errors.New("not an X10 mouse event")
+func parseX10MouseEvent(buf []byte) MouseEvent {
+	v := buf[3:6]
+	var m MouseEvent
+	const byteOffset = 32
+	e := v[0] - byteOffset
+
+	const (
+		bitShift  = 0b0000_0100
+		bitAlt    = 0b0000_1000
+		bitCtrl   = 0b0001_0000
+		bitMotion = 0b0010_0000
+		bitWheel  = 0b0100_0000
+
+		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
 		}
-
-		var m MouseEvent
-		const byteOffset = 32
-		e := v[0] - byteOffset
-
-		const (
-			bitShift  = 0b0000_0100
-			bitAlt    = 0b0000_1000
-			bitCtrl   = 0b0001_0000
-			bitMotion = 0b0010_0000
-			bitWheel  = 0b0100_0000
-
-			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
-			}
-		} 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
-				}
+	} 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
 			}
 		}
+	}
 
-		if e&bitAlt != 0 {
-			m.Alt = true
-		}
-		if e&bitCtrl != 0 {
-			m.Ctrl = true
-		}
-
-		// (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
-
-		r = append(r, m)
+	if e&bitAlt != 0 {
+		m.Alt = true
 	}
+	if e&bitCtrl != 0 {
+		m.Ctrl = true
+	}
+
+	// (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
 
-	return r, nil
+	return m
 }


Test patch:
diff --git a/key_test.go b/key_test.go
index 07c2743..1af6a3e 100644
--- a/key_test.go
+++ b/key_test.go
@@ -2,8 +2,15 @@ package tea
 
 import (
 	"bytes"
+	"flag"
 	"fmt"
+	"math/rand"
+	"reflect"
+	"runtime"
+	"sort"
+	"strings"
 	"testing"
+	"time"
 )
 
 func TestKeyString(t *testing.T) {
@@ -48,13 +55,165 @@ func TestKeyTypeString(t *testing.T) {
 	})
 }
 
+type seqTest struct {
+	seq []byte
+	msg Msg
+}
+
+// buildBaseSeqTests returns sequence tests that are valid for the
+// detectSequence() function.
+func buildBaseSeqTests() []seqTest {
+	td := []seqTest{}
+	for seq, key := range sequences {
+		key := key
+		td = append(td, seqTest{[]byte(seq), KeyMsg(key)})
+		if !key.Alt {
+			key.Alt = true
+			td = append(td, seqTest{[]byte("\x1b" + seq), KeyMsg(key)})
+		}
+	}
+	// Add all the control characters.
+	for i := keyNUL + 1; i <= keyDEL; i++ {
+		if i == keyESC {
+			// Not handled in detectSequence(), so not part of the base test
+			// suite.
+			continue
+		}
+		td = append(td, seqTest{[]byte{byte(i)}, KeyMsg{Type: i}})
+		td = append(td, seqTest{[]byte{'\x1b', byte(i)}, KeyMsg{Type: i, Alt: true}})
+		if i == keyUS {
+			i = keyDEL - 1
+		}
+	}
+
+	// Additional special cases.
+	td = append(td,
+		// Unrecognized CSI sequence.
+		seqTest{
+			[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
+			unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
+		},
+		// A lone space character.
+		seqTest{
+			[]byte{' '},
+			KeyMsg{Type: KeySpace, Runes: []rune(" ")},
+		},
+		// An escape character with the alt modifier.
+		seqTest{
+			[]byte{'\x1b', ' '},
+			KeyMsg{Type: KeySpace, Runes: []rune(" "), Alt: true},
+		},
+	)
+	return td
+}
+
+func TestDetectSequence(t *testing.T) {
+	td := buildBaseSeqTests()
+	for _, tc := range td {
+		t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
+			hasSeq, width, msg := detectSequence(tc.seq)
+			if !hasSeq {
+				t.Fatalf("no sequence found")
+			}
+			if width != len(tc.seq) {
+				t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq))
+			}
+			if !reflect.DeepEqual(tc.msg, msg) {
+				t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg)
+			}
+		})
+	}
+}
+
+func TestDetectOneMsg(t *testing.T) {
+	td := buildBaseSeqTests()
+	// Add tests for the inputs that detectOneMsg() can parse, but
+	// detectSequence() cannot.
+	td = append(td,
+		// Mouse event.
+		seqTest{
+			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
+			MouseMsg{X: 32, Y: 16, Type: MouseWheelUp},
+		},
+		// Runes.
+		seqTest{
+			[]byte{'a'},
+			KeyMsg{Type: KeyRunes, Runes: []rune("a")},
+		},
+		seqTest{
+			[]byte{'\x1b', 'a'},
+			KeyMsg{Type: KeyRunes, Runes: []rune("a"), Alt: true},
+		},
+		seqTest{
+			[]byte{'a', 'a', 'a'},
+			KeyMsg{Type: KeyRunes, Runes: []rune("aaa")},
+		},
+		// Multi-byte rune.
+		seqTest{
+			[]byte("☃"),
+			KeyMsg{Type: KeyRunes, Runes: []rune("☃")},
+		},
+		seqTest{
+			[]byte("\x1b☃"),
+			KeyMsg{Type: KeyRunes, Runes: []rune("☃"), Alt: true},
+		},
+		// Standalone control chacters.
+		seqTest{
+			[]byte{'\x1b'},
+			KeyMsg{Type: KeyEscape},
+		},
+		seqTest{
+			[]byte{byte(keySOH)},
+			KeyMsg{Type: KeyCtrlA},
+		},
+		seqTest{
+			[]byte{'\x1b', byte(keySOH)},
+			KeyMsg{Type: KeyCtrlA, Alt: true},
+		},
+		seqTest{
+			[]byte{byte(keyNUL)},
+			KeyMsg{Type: KeyCtrlAt},
+		},
+		seqTest{
+			[]byte{'\x1b', byte(keyNUL)},
+			KeyMsg{Type: KeyCtrlAt, Alt: true},
+		},
+		// Invalid characters.
+		seqTest{
+			[]byte{'\x80'},
+			unknownInputByteMsg(0x80),
+		},
+	)
+
+	if runtime.GOOS != "windows" {
+		// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
+		// This is incorrect, but it makes our test fail if we try it out.
+		td = append(td, seqTest{
+			[]byte{'\xfe'},
+			unknownInputByteMsg(0xfe),
+		})
+	}
+
+	for _, tc := range td {
+		t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
+			width, msg := detectOneMsg(tc.seq)
+			if width != len(tc.seq) {
+				t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq))
+			}
+			if !reflect.DeepEqual(tc.msg, msg) {
+				t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg)
+			}
+		})
+	}
+}
+
 func TestReadInput(t *testing.T) {
 	type test struct {
 		keyname string
 		in      []byte
 		out     []Msg
 	}
-	for i, td := range []test{
+	testData := []test{
 		{"a",
 			[]byte{'a'},
 			[]Msg{
@@ -73,6 +232,21 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
+		{"a alt+a",
+			[]byte{'a', '\x1b', 'a'},
+			[]Msg{
+				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
+				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true},
+			},
+		},
+		{"a alt+a a",
+			[]byte{'a', '\x1b', 'a', 'a'},
+			[]Msg{
+				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
+				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true},
+				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
+			},
+		},
 		{"ctrl+a",
 			[]byte{byte(keySOH)},
 			[]Msg{
@@ -81,6 +255,13 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
+		{"ctrl+a ctrl+b",
+			[]byte{byte(keySOH), byte(keySTX)},
+			[]Msg{
+				KeyMsg{Type: KeyCtrlA},
+				KeyMsg{Type: KeyCtrlB},
+			},
+		},
 		{"alt+a",
 			[]byte{byte(0x1b), 'a'},
 			[]Msg{
@@ -96,19 +277,7 @@ func TestReadInput(t *testing.T) {
 			[]Msg{
 				KeyMsg{
 					Type:  KeyRunes,
-					Runes: []rune{'a'},
-				},
-				KeyMsg{
-					Type:  KeyRunes,
-					Runes: []rune{'b'},
-				},
-				KeyMsg{
-					Type:  KeyRunes,
-					Runes: []rune{'c'},
-				},
-				KeyMsg{
-					Type:  KeyRunes,
-					Runes: []rune{'d'},
+					Runes: []rune{'a', 'b', 'c', 'd'},
 				},
 			},
 		},
@@ -124,10 +293,30 @@ func TestReadInput(t *testing.T) {
 			[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
 			[]Msg{
 				MouseMsg{
+					X:    32,
+					Y:    16,
 					Type: MouseWheelUp,
 				},
 			},
 		},
+		{"left 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,
+				}),
+				MouseMsg(MouseEvent{
+					X:    64,
+					Y:    32,
+					Type: MouseRelease,
+				}),
+			},
+		},
 		{"shift+tab",
 			[]byte{'\x1b', '[', 'Z'},
 			[]Msg{
@@ -136,6 +325,10 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
+		{"enter",
+			[]byte{'\r'},
+			[]Msg{KeyMsg{Type: KeyEnter}},
+		},
 		{"alt+enter",
 			[]byte{'\x1b', '\r'},
 			[]Msg{
@@ -162,9 +355,9 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
-		{"unrecognized CSI",
+		{"?CSI[45 45 45 45 88]?",
 			[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
-			[]Msg{},
+			[]Msg{unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
 		},
 		// Powershell sequences.
 		{"up",
@@ -191,34 +384,236 @@ func TestReadInput(t *testing.T) {
 			[]byte{'\x1b', '\x7f'},
 			[]Msg{KeyMsg{Type: KeyBackspace, Alt: true}},
 		},
-	} {
+		{"ctrl+@",
+			[]byte{'\x00'},
+			[]Msg{KeyMsg{Type: KeyCtrlAt}},
+		},
+		{"alt+ctrl+@",
+			[]byte{'\x1b', '\x00'},
+			[]Msg{KeyMsg{Type: KeyCtrlAt, Alt: true}},
+		},
+		{"esc",
+			[]byte{'\x1b'},
+			[]Msg{KeyMsg{Type: KeyEsc}},
+		},
+		{"alt+esc",
+			[]byte{'\x1b', '\x1b'},
+			[]Msg{KeyMsg{Type: KeyEsc, Alt: true}},
+		},
+		// Bracketed paste does not work yet.
+		{"?CSI[50 48 48 126]? a   b ?CSI[50 48 49 126]?",
+			[]byte{
+				'\x1b', '[', '2', '0', '0', '~',
+				'a', ' ', 'b',
+				'\x1b', '[', '2', '0', '1', '~'},
+			[]Msg{
+				// What we expect once bracketed paste is recognized properly:
+				//
+				//  KeyMsg{Type: KeyRunes, Runes: []rune("a b")},
+				//
+				// What we get instead (for now):
+				unknownCSISequenceMsg{0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e},
+				KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
+				KeyMsg{Type: KeySpace, Runes: []rune{' '}},
+				KeyMsg{Type: KeyRunes, Runes: []rune{'b'}},
+				unknownCSISequenceMsg{0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e},
+			},
+		},
+	}
+	if runtime.GOOS != "windows" {
+		// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
+		// This is incorrect, but it makes our test fail if we try it out.
+		testData = append(testData,
+			test{"?0xfe?",
+				[]byte{'\xfe'},
+				[]Msg{unknownInputByteMsg(0xfe)},
+			},
+			test{"a ?0xfe?   b",
+				[]byte{'a', '\xfe', ' ', 'b'},
+				[]Msg{
+					KeyMsg{Type: KeyRunes, Runes: []rune{'a'}},
+					unknownInputByteMsg(0xfe),
+					KeyMsg{Type: KeySpace, Runes: []rune{' '}},
+					KeyMsg{Type: KeyRunes, Runes: []rune{'b'}},
+				},
+			},
+		)
+	}
+
+	for i, td := range testData {
 		t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) {
 			msgs, err := readInputs(bytes.NewReader(td.in))
 			if err != nil {
 				t.Fatalf("unexpected error: %v", err)
 			}
+
+			// Compute the title for the event sequence.
+			var buf strings.Builder
+			for i, msg := range msgs {
+				if i > 0 {
+					buf.WriteByte(' ')
+				}
+				if s, ok := msg.(fmt.Stringer); ok {
+					buf.WriteString(s.String())
+				} else {
+					fmt.Fprintf(&buf, "%#v:%T", msg, msg)
+				}
+			}
+			title := buf.String()
+			if title != td.keyname {
+				t.Errorf("expected message titles:\n  %s\ngot:\n  %s", td.keyname, title)
+			}
+
 			if len(msgs) != len(td.out) {
-				t.Fatalf("unexpected message list length")
+				t.Fatalf("unexpected message list length: got %d, expected %d\n%#v", len(msgs), len(td.out), msgs)
 			}
 
-			if len(msgs) == 1 {
-				if m, ok := msgs[0].(KeyMsg); ok && m.String() != td.keyname {
-					t.Fatalf(`expected a keymsg %q, got %q`, td.keyname, m)
-				}
+			if !reflect.DeepEqual(td.out, msgs) {
+				t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, msgs)
 			}
+		})
+	}
+}
 
-			for i, v := range msgs {
-				if m, ok := v.(KeyMsg); ok &&
-					m.String() != td.out[i].(KeyMsg).String() {
-					t.Fatalf(`expected a keymsg %q, got %q`, td.out[i].(KeyMsg), m)
+// randTest defines the test input and expected output for a sequence
+// of interleaved control sequences and control characters.
+type randTest struct {
+	data    []byte
+	lengths []int
+	names   []string
+}
+
+// seed is the random seed to randomize the input. This helps check
+// that all the sequences get ultimately exercised.
+var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)")
+
+// genRandomData generates a randomized test, with a random seed unless
+// the seed flag was set.
+func genRandomData(logfn func(int64), length int) randTest {
+	// We'll use a random source. However, we give the user the option
+	// to override it to a specific value for reproduceability.
+	s := *seed
+	if s == 0 {
+		s = time.Now().UnixNano()
+	}
+	// Inform the user so they know what to reuse to get the same data.
+	logfn(s)
+	return genRandomDataWithSeed(s, length)
+}
+
+// genRandomDataWithSeed generates a randomized test with a fixed seed.
+func genRandomDataWithSeed(s int64, length int) randTest {
+	src := rand.NewSource(s)
+	r := rand.New(src)
+
+	// allseqs contains all the sequences, in sorted order. We sort
+	// to make the test deterministic (when the seed is also fixed).
+	type seqpair struct {
+		seq  string
+		name string
+	}
+	var allseqs []seqpair
+	for seq, key := range sequences {
+		allseqs = append(allseqs, seqpair{seq, key.String()})
+	}
+	sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq })
+
+	// res contains the computed test.
+	var res randTest
+
+	for len(res.data) < length {
+		alt := r.Intn(2)
+		prefix := ""
+		esclen := 0
+		if alt == 1 {
+			prefix = "alt+"
+			esclen = 1
+		}
+		kind := r.Intn(3)
+		switch kind {
+		case 0:
+			// A control character.
+			if alt == 1 {
+				res.data = append(res.data, '\x1b')
+			}
+			res.data = append(res.data, 1)
+			res.names = append(res.names, prefix+"ctrl+a")
+			res.lengths = append(res.lengths, 1+esclen)
+
+		case 1, 2:
+			// A sequence.
+			seqi := r.Intn(len(allseqs))
+			s := allseqs[seqi]
+			if strings.HasPrefix(s.name, "alt+") {
+				esclen = 0
+				prefix = ""
+				alt = 0
+			}
+			if alt == 1 {
+				res.data = append(res.data, '\x1b')
+			}
+			res.data = append(res.data, s.seq...)
+			res.names = append(res.names, prefix+s.name)
+			res.lengths = append(res.lengths, len(s.seq)+esclen)
+		}
+	}
+	return res
+}
+
+// TestDetectRandomSequencesLex checks that the lex-generated sequence
+// detector works over concatenations of random sequences.
+func TestDetectRandomSequencesLex(t *testing.T) {
+	runTestDetectSequence(t, detectSequence)
+}
+
+func runTestDetectSequence(
+	t *testing.T, detectSequence func(input []byte) (hasSeq bool, width int, msg Msg),
+) {
+	for i := 0; i < 10; i++ {
+		t.Run("", func(t *testing.T) {
+			td := genRandomData(func(s int64) { t.Logf("using random seed: %d", s) }, 1000)
+
+			t.Logf("%#v", td)
+
+			// tn is the event number in td.
+			// i is the cursor in the input data.
+			// w is the length of the last sequence detected.
+			for tn, i, w := 0, 0, 0; i < len(td.data); tn, i = tn+1, i+w {
+				hasSequence, width, msg := detectSequence(td.data[i:])
+				if !hasSequence {
+					t.Fatalf("at %d (ev %d): failed to find sequence", i, tn)
 				}
-				if m, ok := v.(MouseMsg); ok &&
-					(mouseEventTypes[m.Type] != td.keyname || m.Type != td.out[i].(MouseMsg).Type) {
-					t.Fatalf(`expected a mousemsg %q, got %q`,
-						td.keyname,
-						mouseEventTypes[td.out[i].(MouseMsg).Type])
+				if width != td.lengths[tn] {
+					t.Errorf("at %d (ev %d): expected width %d, got %d", i, tn, td.lengths[tn], width)
+				}
+				w = width
+
+				s, ok := msg.(fmt.Stringer)
+				if !ok {
+					t.Errorf("at %d (ev %d): expected stringer event, got %T", i, tn, msg)
+				} else {
+					if td.names[tn] != s.String() {
+						t.Errorf("at %d (ev %d): expected event %q, got %q", i, tn, td.names[tn], s.String())
+					}
 				}
 			}
 		})
 	}
 }
+
+// TestDetectRandomSequencesLex checks that the map-based sequence
+// detector works over concatenations of random sequences.
+func TestDetectRandomSequencesMap(t *testing.T) {
+	runTestDetectSequence(t, detectSequence)
+}
+
+// BenchmarkDetectSequenceMap benchmarks the map-based sequence
+// detector.
+func BenchmarkDetectSequenceMap(b *testing.B) {
+	td := genRandomDataWithSeed(123, 10000)
+	for i := 0; i < b.N; i++ {
+		for j, w := 0, 0; j < len(td.data); j += w {
+			_, w, _ = detectSequence(td.data[j:])
+		}
+	}
+}
diff --git a/mouse_test.go b/mouse_test.go
index d9c108f..a64a2e3 100644
--- a/mouse_test.go
+++ b/mouse_test.go
@@ -122,268 +122,209 @@ func TestParseX10MouseEvent(t *testing.T) {
 	tt := []struct {
 		name     string
 		buf      []byte
-		expected []MouseEvent
+		expected MouseEvent
 	}{
 		// Position.
 		{
 			name: "zero position",
 			buf:  encode(0b0010_0000, 0, 0),
-			expected: []MouseEvent{
-				{
-					X:    0,
-					Y:    0,
-					Type: MouseLeft,
-				},
+			expected: MouseEvent{
+				X:    0,
+				Y:    0,
+				Type: MouseLeft,
 			},
 		},
 		{
 			name: "max position",
 			buf:  encode(0b0010_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
-			expected: []MouseEvent{
-				{
-					X:    222,
-					Y:    222,
-					Type: MouseLeft,
-				},
+			expected: MouseEvent{
+				X:    222,
+				Y:    222,
+				Type: MouseLeft,
 			},
 		},
 		// Simple.
 		{
 			name: "left",
 			buf:  encode(0b0000_0000, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseLeft,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseLeft,
 			},
 		},
 		{
 			name: "left in motion",
 			buf:  encode(0b0010_0000, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseLeft,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseLeft,
 			},
 		},
 		{
 			name: "middle",
 			buf:  encode(0b0000_0001, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseMiddle,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseMiddle,
 			},
 		},
 		{
 			name: "middle in motion",
 			buf:  encode(0b0010_0001, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseMiddle,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseMiddle,
 			},
 		},
 		{
 			name: "right",
 			buf:  encode(0b0000_0010, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseRight,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseRight,
 			},
 		},
 		{
 			name: "right in motion",
 			buf:  encode(0b0010_0010, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseRight,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseRight,
 			},
 		},
 		{
 			name: "motion",
 			buf:  encode(0b0010_0011, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseMotion,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseMotion,
 			},
 		},
 		{
 			name: "wheel up",
 			buf:  encode(0b0100_0000, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseWheelUp,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseWheelUp,
 			},
 		},
 		{
 			name: "wheel down",
 			buf:  encode(0b0100_0001, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseWheelDown,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseWheelDown,
 			},
 		},
 		{
 			name: "release",
 			buf:  encode(0b0000_0011, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseRelease,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseRelease,
 			},
 		},
 		// Combinations.
 		{
 			name: "alt+right",
 			buf:  encode(0b0010_1010, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseRight,
-					Alt:  true,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseRight,
+				Alt:  true,
 			},
 		},
 		{
 			name: "ctrl+right",
 			buf:  encode(0b0011_0010, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseRight,
-					Ctrl: true,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseRight,
+				Ctrl: true,
 			},
 		},
 		{
 			name: "ctrl+alt+right",
 			buf:  encode(0b0011_1010, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseRight,
-					Alt:  true,
-					Ctrl: true,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseRight,
+				Alt:  true,
+				Ctrl: true,
 			},
 		},
 		{
 			name: "alt+wheel down",
 			buf:  encode(0b0100_1001, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseWheelDown,
-					Alt:  true,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseWheelDown,
+				Alt:  true,
 			},
 		},
 		{
 			name: "ctrl+wheel down",
 			buf:  encode(0b0101_0001, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseWheelDown,
-					Ctrl: true,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseWheelDown,
+				Ctrl: true,
 			},
 		},
 		{
 			name: "ctrl+alt+wheel down",
 			buf:  encode(0b0101_1001, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseWheelDown,
-					Alt:  true,
-					Ctrl: true,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseWheelDown,
+				Alt:  true,
+				Ctrl: true,
 			},
 		},
 		// Unknown.
 		{
 			name: "wheel with unknown bit",
 			buf:  encode(0b0100_0010, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseUnknown,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseUnknown,
 			},
 		},
 		{
 			name: "unknown with modifier",
 			buf:  encode(0b0100_1010, 32, 16),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseUnknown,
-					Alt:  true,
-				},
+			expected: MouseEvent{
+				X:    32,
+				Y:    16,
+				Type: MouseUnknown,
+				Alt:  true,
 			},
 		},
 		// Overflow position.
 		{
 			name: "overflow position",
 			buf:  encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
-			expected: []MouseEvent{
-				{
-					X:    -6,
-					Y:    -33,
-					Type: MouseLeft,
-				},
-			},
-		},
-		// Batched events.
-		{
-			name: "batched events",
-			buf:  append(encode(0b0010_0000, 32, 16), encode(0b0000_0011, 64, 32)...),
-			expected: []MouseEvent{
-				{
-					X:    32,
-					Y:    16,
-					Type: MouseLeft,
-				},
-				{
-					X:    64,
-					Y:    32,
-					Type: MouseRelease,
-				},
+			expected: MouseEvent{
+				X:    -6,
+				Y:    -33,
+				Type: MouseLeft,
 			},
 		},
 	}
@@ -392,56 +333,13 @@ func TestParseX10MouseEvent(t *testing.T) {
 		tc := tt[i]
 
 		t.Run(tc.name, func(t *testing.T) {
-			actual, err := parseX10MouseEvents(tc.buf)
-			if err != nil {
-				t.Fatalf("unexpected error for test: %v",
-					err,
-				)
-			}
-
-			for i := range tc.expected {
-				if tc.expected[i] != actual[i] {
-					t.Fatalf("expected %#v but got %#v",
-						tc.expected[i],
-						actual[i],
-					)
-				}
-			}
-		})
-	}
-}
-
-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 := parseX10MouseEvents(tc.buf)
+			actual := parseX10MouseEvent(tc.buf)
 
-			if err == nil {
-				t.Fatalf("expected error but got nil")
+			if tc.expected != actual {
+				t.Fatalf("expected %#v but got %#v",
+					tc.expected,
+					actual,
+				)
 			}
 		})
 	}