Repository: charmbracelet/bubbletea (go)

Issue:
Handle batched mouse events
[This snippet](https://github.com/charmbracelet/bubbletea/blob/a6f46172ec4436991b90c2270253b2d212de7ef3/mouse.go#L67) stops all events that are not 6 bytes long from being processed as mouse events.

However, some terminals may emit multiple mouse events per "tick."
- alacritty emits two events when a mouse down and mouse move occur simultaneously, dropping the event
- termux emits the mouse down and mouse up event simultaneously for mouse clicks (this stops all mouse clicks from registering)
  *example of mouse click:*
  ```
  (dlv) p string(buf)
  "\x1b[M +6\x1b[M#+6"
  ```

### Reproducing
- build the "mouse" example with optimizations off `-gcflags "all=-N -l"`
- break on [line 68 of mouse.go](https://github.com/charmbracelet/bubbletea/blob/a6f46172ec4436991b90c2270253b2d212de7ef3/mouse.go#L68)
- trigger breakpoint by moving mouse and pressing mouse button simultaneously

### Expected Behaviour
Should register "batched" mouse events, like ncurses-based apps.

Pull request description:
Support receiving batched mouse events
Mouse events may trigger more than a single event simultaneously.

Fixes #212.

Patch:
diff --git a/README.md b/README.md
index e6e8bd5..8c84953 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and
 complex terminal applications, either inline, full-window, or a mix of both.
 
 <p>
-    <img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif" width="800" alt="Bubble Tea Example">
+    <img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0" width="800" alt="Bubble Tea Example">
 </p>
 
 Bubble Tea is in use in production and includes a number of features and
@@ -288,10 +288,16 @@ For some Bubble Tea programs in production, see:
 * [StormForge Optimize – Controller](https://github.com/thestormforge/optimize-controller): a tool for experimenting with application configurations in Kubernetes
 * [Slides](https://github.com/maaslalani/slides): a markdown-based presentation tool
 * [Typer](https://github.com/maaslalani/typer): a typing test
-* [AT CLI](https://github.com/daskycodes/at_cli): a utility to for executing AT Commands via serial port connections
+* [AT CLI](https://github.com/daskycodes/at_cli): a utility for executing AT Commands via serial port connections
 * [Canard](https://github.com/mrusme/canard): an RSS client
 * [termdbms](https://github.com/mathaou/termdbms): a keyboard and mouse driven database browser
 * [gh-prs](https://www.github.com/dlvhdr/gh-prs): gh cli extension to display a dashboard of PRs
+* [clidle](https://github.com/ajeetdsouza/clidle): a Wordle clone for your terminal
+* [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client
+* [gambit](https://github.com/maaslalani/gambit): play chess in the terminal
+* [gocovsh](https://github.com/orlangure/gocovsh): explore Go coverage reports from the CLI
+* [tran](https://github.com/abdfnx/tran): securely transfer stuff between computers
+* [wishlist](https://github.com/charmbracelet/wishlist): an SSH directory
 
 ## Feedback
 
@@ -316,6 +322,6 @@ Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk.
 
 Part of [Charm](https://charm.sh).
 
-<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge-unrounded.jpg" width="400"></a>
+<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
 
 Charm热爱开源 • Charm loves open source
diff --git a/examples/altscreen-toggle/main.go b/examples/altscreen-toggle/main.go
index c63cee2..539ee52 100644
--- a/examples/altscreen-toggle/main.go
+++ b/examples/altscreen-toggle/main.go
@@ -9,7 +9,7 @@ import (
 )
 
 var (
-	color   = termenv.ColorProfile().Color
+	color   = termenv.EnvColorProfile().Color
 	keyword = termenv.Style{}.Foreground(color("204")).Background(color("235")).Styled
 	help    = termenv.Style{}.Foreground(color("241")).Styled
 )
diff --git a/examples/send-msg/main.go b/examples/send-msg/main.go
index 875f6f8..2146387 100644
--- a/examples/send-msg/main.go
+++ b/examples/send-msg/main.go
@@ -103,16 +103,7 @@ func (m model) View() string {
 func main() {
 	rand.Seed(time.Now().UTC().UnixNano())
 
-	done := make(chan struct{})
-
 	p := tea.NewProgram(newModel())
-	go func() {
-		if err := p.Start(); err != nil {
-			fmt.Println("Error running program:", err)
-			os.Exit(1)
-		}
-		close(done)
-	}()
 
 	// Simulate activity
 	go func() {
@@ -120,12 +111,17 @@ func main() {
 			pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond
 			time.Sleep(pause)
 
-			// Send the Bubble Tea program a message from outside the program.
+			// Send the Bubble Tea program a message from outside the
+			// tea.Program. This will block until it is ready to receive
+			// messages.
 			p.Send(resultMsg{food: randomFood(), duration: pause})
 		}
 	}()
 
-	<-done
+	if err := p.Start(); err != nil {
+		fmt.Println("Error running program:", err)
+		os.Exit(1)
+	}
 }
 
 func randomFood() string {
diff --git a/examples/views/main.go b/examples/views/main.go
index 5b4ed92..1a934be 100644
--- a/examples/views/main.go
+++ b/examples/views/main.go
@@ -28,7 +28,7 @@ const (
 
 // General stuff for styling the view
 var (
-	term          = termenv.ColorProfile()
+	term          = termenv.EnvColorProfile()
 	keyword       = makeFgStyle("211")
 	subtle        = makeFgStyle("241")
 	progressEmpty = subtle(progressEmptyChar)
diff --git a/key.go b/key.go
index ae4bf1a..9714444 100644
--- a/key.go
+++ b/key.go
@@ -292,9 +292,9 @@ var hexes = map[string]Key{
 	"1b4f44": {Type: KeyLeft, Alt: false},
 }
 
-// readInput reads keypress and mouse input from a TTY and returns a message
-// containing information about the key or mouse event accordingly.
-func readInput(input io.Reader) (Msg, error) {
+// 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
 
 	// Read and block
@@ -305,20 +305,28 @@ func readInput(input io.Reader) (Msg, error) {
 
 	// See if it's a mouse event. For now we're parsing X10-type mouse events
 	// only.
-	mouseEvent, err := parseX10MouseEvent(buf[:numBytes])
+	mouseEvent, err := parseX10MouseEvents(buf[:numBytes])
 	if err == nil {
-		return MouseMsg(mouseEvent), nil
+		var m []Msg
+		for _, v := range mouseEvent {
+			m = append(m, MouseMsg(v))
+		}
+		return m, nil
 	}
 
 	// Is it a special sequence, like an arrow key?
 	if k, ok := sequences[string(buf[:numBytes])]; ok {
-		return KeyMsg(Key{Type: k}), nil
+		return []Msg{
+			KeyMsg(Key{Type: k}),
+		}, nil
 	}
 
 	// Some of these need special handling
 	hex := fmt.Sprintf("%x", buf[:numBytes])
 	if k, ok := hexes[hex]; ok {
-		return KeyMsg(k), nil
+		return []Msg{
+			KeyMsg(k),
+		}, nil
 	}
 
 	// Is the alt key pressed? The buffer will be prefixed with an escape
@@ -330,7 +338,9 @@ func readInput(input io.Reader) (Msg, error) {
 		if c == utf8.RuneError {
 			return nil, errors.New("could not decode rune after removing initial escape")
 		}
-		return KeyMsg(Key{Alt: true, Type: KeyRunes, Runes: []rune{c}}), nil
+		return []Msg{
+			KeyMsg(Key{Alt: true, Type: KeyRunes, Runes: []rune{c}}),
+		}, nil
 	}
 
 	var runes []rune
@@ -353,15 +363,21 @@ func readInput(input io.Reader) (Msg, error) {
 	} else if len(runes) > 1 {
 		// We received multiple runes, so we know this isn't a control
 		// character, sequence, and so on.
-		return KeyMsg(Key{Type: KeyRunes, Runes: runes}), nil
+		return []Msg{
+			KeyMsg(Key{Type: KeyRunes, Runes: runes}),
+		}, nil
 	}
 
 	// Is the first rune a control character?
 	r := KeyType(runes[0])
 	if numBytes == 1 && r <= keyUS || r == keyDEL {
-		return KeyMsg(Key{Type: r}), nil
+		return []Msg{
+			KeyMsg(Key{Type: r}),
+		}, nil
 	}
 
 	// Welp, it's just a regular, ol' single rune
-	return KeyMsg(Key{Type: KeyRunes, Runes: runes}), nil
+	return []Msg{
+		KeyMsg(Key{Type: KeyRunes, Runes: runes}),
+	}, nil
 }
diff --git a/mouse.go b/mouse.go
index 6cca0b6..5fb7427 100644
--- a/mouse.go
+++ b/mouse.go
@@ -1,6 +1,9 @@
 package tea
 
-import "errors"
+import (
+	"bytes"
+	"errors"
+)
 
 // 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
@@ -55,78 +58,92 @@ var mouseEventTypes = map[MouseEventType]string{
 	MouseMotion:    "motion",
 }
 
-// Parse an X10-encoded mouse event; the simplest kind. The last release of
-// X10 was December 1986, by the way.
+// Parse X10-encoded mouse events; the simplest kind. The last release of X10
+// was December 1986, by the way.
 //
 // X10 mouse events look like:
 //
 //     ESC [M Cb Cx Cy
 //
 // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
-func parseX10MouseEvent(buf []byte) (m MouseEvent, err error) {
-	if len(buf) != 6 || string(buf[:3]) != "\x1b[M" {
-		return m, errors.New("not an X10 mouse event")
-	}
-
-	const byteOffset = 32
-
-	e := buf[3] - byteOffset
+func parseX10MouseEvents(buf []byte) ([]MouseEvent, error) {
+	var r []MouseEvent
 
-	const (
-		bitShift  = 0b0000_0100
-		bitAlt    = 0b0000_1000
-		bitCtrl   = 0b0001_0000
-		bitMotion = 0b0010_0000
-		bitWheel  = 0b0100_0000
-
-		bitsMask = 0b0000_0011
+	seq := []byte("\x1b[M")
+	if !bytes.Contains(buf, seq) {
+		return r, errors.New("not an X10 mouse event")
+	}
 
-		bitsLeft    = 0b0000_0000
-		bitsMiddle  = 0b0000_0001
-		bitsRight   = 0b0000_0010
-		bitsRelease = 0b0000_0011
+	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")
+		}
 
-		bitsWheelUp   = 0b0000_0000
-		bitsWheelDown = 0b0000_0001
-	)
+		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
+				}
+			}
+		}
 
-	if e&bitWheel != 0 {
-		// Check the low two bits.
-		switch e & bitsMask {
-		case bitsWheelUp:
-			m.Type = MouseWheelUp
-		case bitsWheelDown:
-			m.Type = MouseWheelDown
+		if e&bitAlt != 0 {
+			m.Alt = true
 		}
-	} 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&bitCtrl != 0 {
+			m.Ctrl = true
 		}
-	}
 
-	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
 
-	// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
-	m.X = int(buf[4]) - byteOffset - 1
-	m.Y = int(buf[5]) - byteOffset - 1
+		r = append(r, m)
+	}
 
-	return m, nil
+	return r, nil
 }
diff --git a/standard_renderer.go b/standard_renderer.go
index 765cf91..33d14a7 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -32,6 +32,7 @@ type standardRenderer struct {
 	lastRender        string
 	linesRendered     int
 	useANSICompressor bool
+	once              sync.Once
 
 	// essentially whether or not we're using the full size of the terminal
 	altScreenActive bool
@@ -72,7 +73,9 @@ func (r *standardRenderer) start() {
 func (r *standardRenderer) stop() {
 	r.flush()
 	clearLine(r.out)
-	close(r.done)
+	r.once.Do(func() {
+		close(r.done)
+	})
 
 	if r.useANSICompressor {
 		if w, ok := r.out.(io.WriteCloser); ok {
@@ -84,7 +87,9 @@ func (r *standardRenderer) stop() {
 // kill halts the renderer. The final frame will not be rendered.
 func (r *standardRenderer) kill() {
 	clearLine(r.out)
-	close(r.done)
+	r.once.Do(func() {
+		close(r.done)
+	})
 }
 
 // listen waits for ticks on the ticker, or a signal to stop the renderer.
diff --git a/tea.go b/tea.go
index fdf29ee..b883fe8 100644
--- a/tea.go
+++ b/tea.go
@@ -97,6 +97,8 @@ type Program struct {
 	// is on by default.
 	CatchPanics bool
 
+	killc chan bool
+
 	console console.Console
 
 	// Stores the original reference to stdin for cases where input is not a
@@ -119,11 +121,18 @@ type Program struct {
 //     }
 //
 func Batch(cmds ...Cmd) Cmd {
-	if len(cmds) == 0 {
+	var validCmds []Cmd
+	for _, c := range cmds {
+		if c == nil {
+			continue
+		}
+		validCmds = append(validCmds, c)
+	}
+	if len(validCmds) == 0 {
 		return nil
 	}
 	return func() Msg {
-		return batchMsg(cmds)
+		return batchMsg(validCmds)
 	}
 }
 
@@ -241,6 +250,7 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 		input:        os.Stdin,
 		msgs:         make(chan Msg),
 		CatchPanics:  true,
+		killc:        make(chan bool, 1),
 	}
 
 	// Apply all options to the program.
@@ -411,7 +421,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 					return
 				}
 
-				msg, err := readInput(cancelReader)
+				msgs, err := readInputs(cancelReader)
 				if err != nil {
 					if !errors.Is(err, io.EOF) && !errors.Is(err, errCanceled) {
 						errs <- err
@@ -420,7 +430,9 @@ func (p *Program) StartReturningModel() (Model, error) {
 					return
 				}
 
-				p.msgs <- msg
+				for _, msg := range msgs {
+					p.msgs <- msg
+				}
 			}
 		}()
 	} else {
@@ -479,6 +491,8 @@ func (p *Program) StartReturningModel() (Model, error) {
 	// Handle updates and draw.
 	for {
 		select {
+		case <-p.killc:
+			return nil, nil
 		case err := <-errs:
 			cancelContext()
 			waitForGoroutines(cancelReader.Cancel())
@@ -570,13 +584,25 @@ func (p *Program) Quit() {
 	p.Send(Quit())
 }
 
+// Kill stops the program immediately and restores the former terminal state.
+// The final render that you would normally see when quitting will be skipped.
+//
+// This method is currently provisional. The method signature may alter
+// slightly, or it may be removed in a future version of this package.
+func (p *Program) Kill() {
+	p.killc <- true
+	p.shutdown(true)
+}
+
 // shutdown performs operations to free up resources and restore the terminal
 // to its original state.
 func (p *Program) shutdown(kill bool) {
-	if kill {
-		p.renderer.kill()
-	} else {
-		p.renderer.stop()
+	if p.renderer != nil {
+		if kill {
+			p.renderer.kill()
+		} else {
+			p.renderer.stop()
+		}
 	}
 	p.ExitAltScreen()
 	p.DisableMouseCellMotion()


Test patch:
diff --git a/commands_test.go b/commands_test.go
index 749c6a9..e0d5ff3 100644
--- a/commands_test.go
+++ b/commands_test.go
@@ -80,3 +80,28 @@ func TestSequentially(t *testing.T) {
 		})
 	}
 }
+
+func TestBatch(t *testing.T) {
+	t.Run("nil cmd", func(t *testing.T) {
+		if b := Batch(nil); b != nil {
+			t.Fatalf("expected nil, got %+v", b)
+		}
+	})
+	t.Run("empty cmd", func(t *testing.T) {
+		if b := Batch(); b != nil {
+			t.Fatalf("expected nil, got %+v", b)
+		}
+	})
+	t.Run("single cmd", func(t *testing.T) {
+		b := Batch(Quit)()
+		if l := len(b.(batchMsg)); l != 1 {
+			t.Fatalf("expected a []Cmd with len 1, got %d", l)
+		}
+	})
+	t.Run("mixed nil cmds", func(t *testing.T) {
+		b := Batch(nil, Quit, nil, Quit, nil, nil)()
+		if l := len(b.(batchMsg)); l != 2 {
+			t.Fatalf("expected a []Cmd with len 2, got %d", l)
+		}
+	})
+}
diff --git a/key_test.go b/key_test.go
index ecb551c..931fa84 100644
--- a/key_test.go
+++ b/key_test.go
@@ -58,14 +58,18 @@ func TestReadInput(t *testing.T) {
 		"shift+tab": {'\x1b', '[', 'Z'},
 	} {
 		t.Run(out, func(t *testing.T) {
-			msg, err := readInput(bytes.NewReader(in))
+			msgs, err := readInputs(bytes.NewReader(in))
 			if err != nil {
 				t.Fatalf("unexpected error: %v", err)
 			}
-			if m, ok := msg.(KeyMsg); ok && m.String() != out {
+			if len(msgs) == 0 {
+				t.Fatalf("unexpected empty message list")
+			}
+
+			if m, ok := msgs[0].(KeyMsg); ok && m.String() != out {
 				t.Fatalf(`expected a keymsg %q, got %q`, out, m)
 			}
-			if m, ok := msg.(MouseMsg); ok && mouseEventTypes[m.Type] != out {
+			if m, ok := msgs[0].(MouseMsg); ok && mouseEventTypes[m.Type] != out {
 				t.Fatalf(`expected a mousemsg %q, got %q`, out, mouseEventTypes[m.Type])
 			}
 		})
diff --git a/mouse_test.go b/mouse_test.go
index 9a4df8a..d9c108f 100644
--- a/mouse_test.go
+++ b/mouse_test.go
@@ -122,209 +122,268 @@ 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,
+			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,
+				},
 			},
 		},
 	}
@@ -333,18 +392,20 @@ func TestParseX10MouseEvent(t *testing.T) {
 		tc := tt[i]
 
 		t.Run(tc.name, func(t *testing.T) {
-			actual, err := parseX10MouseEvent(tc.buf)
+			actual, err := parseX10MouseEvents(tc.buf)
 			if err != nil {
-				t.Fatalf("unexpected error: %v",
+				t.Fatalf("unexpected error for test: %v",
 					err,
 				)
 			}
 
-			if tc.expected != actual {
-				t.Fatalf("expected %#v but got %#v",
-					tc.expected,
-					actual,
-				)
+			for i := range tc.expected {
+				if tc.expected[i] != actual[i] {
+					t.Fatalf("expected %#v but got %#v",
+						tc.expected[i],
+						actual[i],
+					)
+				}
 			}
 		})
 	}
@@ -377,7 +438,7 @@ func TestParseX10MouseEvent_error(t *testing.T) {
 		tc := tt[i]
 
 		t.Run(tc.name, func(t *testing.T) {
-			_, err := parseX10MouseEvent(tc.buf)
+			_, err := parseX10MouseEvents(tc.buf)
 
 			if err == nil {
 				t.Fatalf("expected error but got nil")