Was having some issues on MacOS thinking the program I was writing was crashing when scrolling, turned out to just be some scroll event stuff leaking through into the input handling of my program as received through the library. Scrolling does work normally at slower scrolling speeds, but on the macOS touchpad it can send these events out in a way that was not being handled in zigzag.
Iterated through the problem with deepseek v4 flash, tried to keep the changes as minimal and non-invasive as possible. Only really tested them on MacOS for now though, so results may vary. Did a lot of spamming keys and scrolling up/down in various combinations to catch a few of the leaks.
Platform
- OS: macOS (Darwin)
- Zig version: 0.16.0-dev
- Terminal: any with mouse tracking (Terminal.app, iTerm2, etc.)
- Read buffer size: 256 bytes (stack buffer in
program.zig:tick())
Description
When scrolling rapidly with mouse tracking enabled (.mouse = true), SGR-encoded mouse sequences like \x1b[<64;65;42M (button=64=wheel_up, x=65, y=42) are split across the 256-byte input read buffer boundary. The parser consumes the leading \x1b as an Escape key press, and the remaining bytes [<64;65;42M leak through as individual character key events ([, <, 6, 4, ;...). These appear as garbage in text input or inadvertently activate quit-key bindings.
Root cause
Two issues in src/input/keyboard.zig:
1. parse() — incomplete CSI/SS3 fallthrough (lines 34-46)
When parseCsi() or parseSs3() returns null (the sequence started with \x1b[ or \x1bO but the data was truncated by the buffer boundary), the function falls through to the default case at line 58 which consumes \x1b as an Escape key press (consumed=1). The truncated bytes remaining in the buffer are then reparsed as individual UTF-8 characters. The function has no way to distinguish "this sequence is malformed/complete" from "this sequence is incomplete because the data was split across the buffer".
2. parseAll() — no cross-call state (lines 313-329)
parseAll() takes a []const u8 buffer and returns []ParseResult. Any bytes not consumed by parse() (when it returns consumed=0) are simply left in the stack buffer which is discarded at end of frame. There is no mechanism to preserve partial sequences across calls. A split \x1b[<65;39;17 loses the first chunk; the second chunk M arrives in the next frame as fresh data — missing the leading \x1b — and leaks as an individual UTF-8 character.
Reproduction
- Enable mouse tracking:
Options{ .mouse = true }
- Scroll rapidly using a trackpad or mouse wheel
- Observe garbage characters (
[<digits;digits;digitsM) appearing in text input
- If leaked bytes happen to contain quit-key characters (e.g.
q), the application exits unexpectedly with exit code 0
Fix
All changes in src/input/keyboard.zig — no other files touched:
diff --git a/src/input/keyboard.zig b/src/input/keyboard.zig
index 5b39090..fe8dfd6 100644
--- a/src/input/keyboard.zig
+++ b/src/input/keyboard.zig
@@ -36,6 +36,9 @@ pub fn parse(data: []const u8) ParseReturn {
if (parseCsi(data)) |result| {
return result;
}
+ // CSI started (ESC[) but couldn't complete — data likely split
+ // across buffer boundary. Don't consume the escape.
+ return .{ .result = .none, .consumed = 0 };
}
// SS3 sequence (F1-F4 on some terminals)
@@ -43,6 +46,8 @@ pub fn parse(data: []const u8) ParseReturn {
if (parseSs3(data)) |result| {
return result;
}
+ // SS3 started (ESCO) but couldn't complete — same deal.
+ return .{ .result = .none, .consumed = 0 };
}
// Alt + key
@@ -309,14 +314,34 @@ fn parseSs3(data: []const u8) ?ParseReturn {
return .{ .result = .{ .key = .{ .key = key } }, .consumed = 3 };
}
-/// Parse all available input events from a buffer
+var pending_buf: [256]u8 = undefined;
+var pending_len: usize = 0;
+
+/// Parse all available input events from a buffer.
+/// Preserves partial escape sequences across calls by buffering unconsumed
+/// tail bytes internally. Needed when a CSI/SS3 sequence is split across
+/// the caller's buffer boundary (e.g. SGR mouse sequences during rapid
+/// scrolling in a 256-byte read buffer).
pub fn parseAll(allocator: std.mem.Allocator, data: []const u8) ![]ParseResult {
+ var buf: [512]u8 = undefined;
+ var buf_len: usize = 0;
+
+ if (pending_len > 0) {
+ @memcpy(buf[0..pending_len], pending_buf[0..pending_len]);
+ buf_len += pending_len;
+ pending_len = 0;
+ }
+
+ const copy = @min(data.len, buf.len - buf_len);
+ @memcpy(buf[buf_len..][0..copy], data[0..copy]);
+ buf_len += copy;
+
var results = std.array_list.Managed(ParseResult).init(allocator);
errdefer results.deinit();
var offset: usize = 0;
- while (offset < data.len) {
- const parsed = parse(data[offset..]);
+ while (offset < buf_len) {
+ const parsed = parse(buf[offset..buf_len]);
if (parsed.consumed == 0) break;
if (parsed.result != .none) {
@@ -325,5 +350,11 @@ pub fn parseAll(allocator: std.mem.Allocator, data: []const u8) ![]ParseResult {
offset += parsed.consumed;
}
+ if (offset < buf_len) {
+ const leftover = buf_len - offset;
+ @memcpy(pending_buf[0..leftover], buf[offset..][0..leftover]);
+ pending_len = leftover;
+ }
+
return results.toOwnedSlice();
}
Was having some issues on MacOS thinking the program I was writing was crashing when scrolling, turned out to just be some scroll event stuff leaking through into the input handling of my program as received through the library. Scrolling does work normally at slower scrolling speeds, but on the macOS touchpad it can send these events out in a way that was not being handled in zigzag.
Iterated through the problem with deepseek v4 flash, tried to keep the changes as minimal and non-invasive as possible. Only really tested them on MacOS for now though, so results may vary. Did a lot of spamming keys and scrolling up/down in various combinations to catch a few of the leaks.
Platform
program.zig:tick())Description
When scrolling rapidly with mouse tracking enabled (
.mouse = true), SGR-encoded mouse sequences like\x1b[<64;65;42M(button=64=wheel_up, x=65, y=42) are split across the 256-byte input read buffer boundary. The parser consumes the leading\x1bas an Escape key press, and the remaining bytes[<64;65;42Mleak through as individual character key events ([,<,6,4,;...). These appear as garbage in text input or inadvertently activate quit-key bindings.Root cause
Two issues in
src/input/keyboard.zig:1.
parse()— incomplete CSI/SS3 fallthrough (lines 34-46)When
parseCsi()orparseSs3()returns null (the sequence started with\x1b[or\x1bObut the data was truncated by the buffer boundary), the function falls through to the default case at line 58 which consumes\x1bas an Escape key press (consumed=1). The truncated bytes remaining in the buffer are then reparsed as individual UTF-8 characters. The function has no way to distinguish "this sequence is malformed/complete" from "this sequence is incomplete because the data was split across the buffer".2.
parseAll()— no cross-call state (lines 313-329)parseAll()takes a[]const u8buffer and returns[]ParseResult. Any bytes not consumed byparse()(when it returnsconsumed=0) are simply left in the stack buffer which is discarded at end of frame. There is no mechanism to preserve partial sequences across calls. A split\x1b[<65;39;17loses the first chunk; the second chunkMarrives in the next frame as fresh data — missing the leading\x1b— and leaks as an individual UTF-8 character.Reproduction
Options{ .mouse = true }[<digits;digits;digitsM) appearing in text inputq), the application exits unexpectedly with exit code 0Fix
All changes in
src/input/keyboard.zig— no other files touched: