From cea01b8c08c30fa68fcbf18266937274a080507e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 01:42:35 -0300 Subject: Send mouse events over the wire, thinned, and check the two things only hardware can ## The console thins mouse reports The board asks for DEC 1002, so the terminal reports presses, releases and motion while a button is held. A press is one report; a DRAG is one report per cell crossed, each about a dozen bytes. A hand crosses forty cells in a tenth of a second, which is ~500 bytes, which is 43 ms of a 115200 line - and every one of those bytes is input the board must parse while it is trying to paint the result of the previous one. Unthinned, a drag makes the editor unusable for as long as the drag lasts and for a while after. `MouseFilter` holds the newest motion report and drops the ones it supersedes, on a 40 ms window: ~25 reports a second, about 2.6% of the line. Newest-wins is right for motion and only for motion - where the pointer PASSED THROUGH is not information the editor can use, since a selection is defined by where the drag began and where it is now, so an intermediate report already stale by the time it reaches the wire is pure cost. Presses, releases and wheel notches are never held: each one means something different and dropping one loses a click. Ordering is the part worth testing. A held report is released before any non-motion byte that follows it, so a release cannot overtake the motion it ends, and a drag that stops moving still delivers its final position when the window expires. The seven tests cover those, a report split across a read boundary, a non-mouse escape sequence passing through untouched, and - the one that would hurt most - a lone ESC not being swallowed, because that is how you leave insert mode. ## Two hardware checks: p4-bench --check Both are regressions that no host test can see and no latency number can show. A lone Escape still leaves insert mode. The shell now holds a solitary ESC for 10 ms because on this wire the first byte of every sequence arrives alone; if that hold ever stops expiring, Escape stops working and the editor is unusable. A click split byte-by-byte lands at the column clicked. This is the bug that made the mouse look unimplemented, and it only appears when the bytes arrive separately - which the wire does anyway, 87 us apart. The check sends them as ten separate writes with no gap, because a gap longer than the hold would expire it and the check would be exercising nothing. Two things this file deliberately does NOT check, both because a check that cannot fail honestly is worse than no check. Input loss during transmit belongs to the deterministic host test in `src/pardes/input_rescue.zig`, which loses 67 bytes with the fix removed and needs no board. And every hardware oracle for it that was tried here was worse: the cursor stops being reported past 160 characters because the wrapped line outgrows the viewport, and a screen reconstruction cannot be rebuilt mid-session because the board only sends what changed. Both false starts are recorded in the file so the next person does not repeat them. The check also found its own bugs before it found any of the firmware's: 12 ms gaps between the click's bytes expired the very hold it meant to test, `$` produces no frame when the cursor is already at the end of the line, and reading the cursor after a press alone measures the revert rather than the click. --- tools/console.zig | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 257 insertions(+), 6 deletions(-) (limited to 'tools/console.zig') diff --git a/tools/console.zig b/tools/console.zig index 2e924e3..4b13513 100644 --- a/tools/console.zig +++ b/tools/console.zig @@ -98,6 +98,156 @@ const ModeWatch = struct { } }; +/// How long a motion report may be held while a newer one might replace it. +/// +/// A report is ~12 bytes, so 40 ms caps drag traffic at 25 reports a second - about 300 B/s, or 2.6% +/// of a 115200 line. Below roughly 30 ms the thinning stops paying for itself on this link; far above +/// it a drag visibly lags the pointer. +const coalesce_ms: i64 = 40; + +/// Thins out mouse reports on their way to the board. +/// +/// The board asks for DEC 1002, so the terminal reports presses, releases, and motion WHILE A BUTTON +/// IS HELD. A press is one report; a drag across this grid is one report per cell crossed, each +/// `\x1b[<0;12;5M` at roughly a dozen bytes. A hand can cross forty cells in a tenth of a second, +/// which is ~500 bytes, which is 43 ms of a 115200 line - and every one of those bytes is input the +/// board has to parse while it is trying to paint the result of the previous one. Unthinned, a drag +/// makes the editor unusable for as long as the drag lasts and for a while after. +/// +/// The thinning is NEWEST-WINS, and only for motion. Where the pointer passed through is not +/// information the editor can use - a selection is defined by where the drag started and where it is +/// now - so an intermediate report that is already stale by the time it reaches the wire is pure +/// cost. Presses, releases and wheel events are never held: those are discrete, each one means +/// something different, and dropping one loses a click. +/// +/// A held report is released when the interval expires or when any non-motion byte follows it, so a +/// drag that stops moving still delivers its final position, and a release always arrives after the +/// motion that preceded it. +const MouseFilter = struct { + /// The most recent motion report not yet sent, if any. + held: [max_report]u8 = undefined, + held_len: usize = 0, + /// A report arriving in pieces across reads. SGR reports are short, but a read boundary can + /// still land inside one, and a half-parsed report must not be forwarded as loose bytes. + partial: [max_report]u8 = undefined, + partial_len: usize = 0, + dropped: usize = 0, + + /// Longest `\x1b[ 0) { + m.partial[m.partial_len] = in[i]; + m.partial_len += 1; + i += 1; + switch (classify(m.partial[0..m.partial_len])) { + .incomplete => if (m.partial_len < max_report) continue else { + // Too long to be a report: it was never one, so pass it on untouched. + n += m.flushHeld(out[n..]); + @memcpy(out[n..][0..m.partial_len], m.partial[0..m.partial_len]); + n += m.partial_len; + m.partial_len = 0; + continue; + }, + .motion => { + m.hold(m.partial[0..m.partial_len]); + m.partial_len = 0; + continue; + }, + .other => { + n += m.flushHeld(out[n..]); + @memcpy(out[n..][0..m.partial_len], m.partial[0..m.partial_len]); + n += m.partial_len; + m.partial_len = 0; + continue; + }, + } + } + // A report can only start at an ESC. + if (in[i] == 0x1b) { + m.partial[0] = in[i]; + m.partial_len = 1; + i += 1; + continue; + } + // Ordinary byte: it orders after anything held, so the held report goes first. + n += m.flushHeld(out[n..]); + out[n] = in[i]; + n += 1; + i += 1; + } + return n; + } + + fn hold(m: *MouseFilter, report: []const u8) void { + if (m.held_len > 0) m.dropped += 1; + @memcpy(m.held[0..report.len], report); + m.held_len = report.len; + } + + /// Emit the held report, if there is one. Called when ordering requires it and by the caller when + /// the coalescing interval expires. + fn flushHeld(m: *MouseFilter, out: []u8) usize { + if (m.held_len == 0) return 0; + @memcpy(out[0..m.held_len], m.held[0..m.held_len]); + const n = m.held_len; + m.held_len = 0; + return n; + } + + fn pending(m: *const MouseFilter) bool { + return m.held_len > 0; + } + + const Kind = enum { incomplete, motion, other }; + + /// Is `bytes` a complete SGR mouse report, and is it motion? + /// + /// `\x1b[<` then decimal parameters separated by `;` then `M` (press or motion) or `m` (release). + /// Motion is the low two bits of the button field being 3 for a plain move, or bit 5 (32) set for + /// a drag; a wheel report has bit 6 (64) set and is never motion however it is encoded. + fn classify(bytes: []const u8) Kind { + if (bytes.len < 3) { + const prefix = "\x1b[<"; + return if (std.mem.startsWith(u8, prefix, bytes)) .incomplete else .other; + } + if (!std.mem.startsWith(u8, bytes, "\x1b[<")) return .other; + var button: u32 = 0; + var digits: usize = 0; + var i: usize = 3; + while (i < bytes.len) : (i += 1) { + const b = bytes[i]; + if (b >= '0' and b <= '9') { + if (digits == 0) button = button * 10 + (b - '0'); + if (button > 1 << 20) return .other; + continue; + } + if (b == ';') { + digits += 1; + continue; + } + if (b == 'M' or b == 'm') { + if (digits != 2) return .other; + const wheel = button & 64 != 0; + const drag = button & 32 != 0; + return if (!wheel and drag) .motion else .other; + } + return .other; + } + return .incomplete; + } +}; + pub const Options = struct { /// Pulse reset so the application starts from boot with the console already attached. Without /// it, attaching to a board that has been running for a while shows a screen mid-session with @@ -164,8 +314,11 @@ pub fn attach(port_path: []const u8, baud: serial.Baud, opts: Options) !void { sendWinsize(&port, windowSize()); var watch: ModeWatch = .{}; + var mouse: MouseFilter = .{}; + var held_at: ?i64 = null; var from_board: [4096]u8 = undefined; var from_user: [256]u8 = undefined; + var to_board: [512]u8 = undefined; while (true) { if (@as(*volatile bool, &winch_pending).*) { @@ -180,8 +333,14 @@ pub fn attach(port_path: []const u8, baud: serial.Baud, opts: Options) !void { // A bounded wait rather than an infinite one so a SIGWINCH that lands between the check // above and the poll below is still serviced promptly; poll reports the signal itself as // an interrupt, which is handled as "go round again". - const ready = posix.poll(&pfd, 200) catch continue; - if (ready == 0) continue; + // A bounded wait, and shorter while a motion report is being held: the hold has to end on + // time even when the human has stopped moving the mouse and nothing else is arriving. + const wait: i32 = if (held_at) |at| blk: { + const left = coalesce_ms - (std.time.milliTimestamp() - at); + break :blk if (left <= 0) 0 else @intCast(left); + } else 200; + const ready = posix.poll(&pfd, wait) catch continue; + if (ready == 0 and held_at == null) continue; // POLL.IN is not the only thing poll reports, and ignoring the rest is a hot spin, not a // no-op: unplug the CH340 mid-session and the port's revents carries HUP|ERR|NVAL forever. @@ -207,14 +366,33 @@ pub fn attach(port_path: []const u8, baud: serial.Baud, opts: Options) !void { if (pfd[0].revents & posix.POLL.IN != 0) { const n = stdin.readStreaming(io, &.{&from_user}) catch 0; if (n == 0) return; // stdin closed: a pipe ran out, so detach - if (std.mem.indexOfScalar(u8, from_user[0..n], escape_byte)) |cut| { - // Everything before the escape still belongs to the board. - if (cut > 0) port.write(from_user[0..cut]) catch {}; + // The escape byte is looked for in the RAW stream, before any filtering: Ctrl-] has to + // detach whatever else is in flight, including a half-parsed mouse report. + var raw = from_user[0..n]; + const detaching = std.mem.indexOfScalar(u8, raw, escape_byte); + if (detaching) |cut| raw = raw[0..cut]; + const send = mouse.feed(raw, &to_board); + if (send > 0) port.write(to_board[0..send]) catch {}; + if (detaching != null) { + // Anything still held belongs to the board before we go. + const tail = mouse.flushHeld(&to_board); + if (tail > 0) port.write(to_board[0..tail]) catch {}; if (opts.banner) stdout.writeStreamingAll(io, "\r\n[detached]\r\n") catch {}; return; } - port.write(from_user[0..n]) catch {}; } + + // The coalescing window. A held motion report goes out when the interval has elapsed, which + // is what turns a drag into a bounded stream of positions rather than one per cell crossed. + if (mouse.pending()) { + const at = held_at orelse std.time.milliTimestamp(); + held_at = at; + if (std.time.milliTimestamp() - at >= coalesce_ms) { + const send = mouse.flushHeld(&to_board); + if (send > 0) port.write(to_board[0..send]) catch {}; + held_at = null; + } + } else held_at = null; } } @@ -237,3 +415,76 @@ test "ModeWatch ignores unrelated traffic" { var m: ModeWatch = .{}; for ("hello \x1b[?1049h world \x1b[0m") |b| try std.testing.expect(!m.feed(b)); } + +// ------------------------------------------------------------------------------- the mouse filter + +test "MouseFilter passes an ordinary keystroke straight through" { + var m: MouseFilter = .{}; + var out: [64]u8 = undefined; + const n = m.feed("hello", &out); + try std.testing.expectEqualStrings("hello", out[0..n]); + try std.testing.expect(!m.pending()); +} + +test "MouseFilter never holds a press, a release or a wheel" { + var m: MouseFilter = .{}; + var out: [64]u8 = undefined; + // 0 = left press, 0 with 'm' = release, 64/65 = wheel up/down. All discrete: dropping one loses + // a click or a scroll notch, so none of them may be coalesced. + for ([_][]const u8{ "\x1b[<0;10;5M", "\x1b[<0;10;5m", "\x1b[<64;10;5M", "\x1b[<65;10;5M" }) |report| { + const n = m.feed(report, &out); + try std.testing.expectEqualStrings(report, out[0..n]); + try std.testing.expect(!m.pending()); + } +} + +test "MouseFilter keeps only the newest drag position" { + var m: MouseFilter = .{}; + var out: [64]u8 = undefined; + // 32 = motion with the left button held: a drag. Three cells crossed in one read. + const n = m.feed("\x1b[<32;10;5M\x1b[<32;11;5M\x1b[<32;12;5M", &out); + try std.testing.expectEqual(@as(usize, 0), n); // nothing goes out yet + try std.testing.expect(m.pending()); + try std.testing.expectEqual(@as(usize, 2), m.dropped); + const flushed = m.flushHeld(&out); + try std.testing.expectEqualStrings("\x1b[<32;12;5M", out[0..flushed]); +} + +test "MouseFilter releases a held drag before anything that follows it" { + var m: MouseFilter = .{}; + var out: [64]u8 = undefined; + // The release must not overtake the motion that preceded it, or the editor ends a selection at + // the wrong cell. + const n = m.feed("\x1b[<32;10;5M\x1b[<0;12;5m", &out); + try std.testing.expectEqualStrings("\x1b[<32;10;5M\x1b[<0;12;5m", out[0..n]); + try std.testing.expect(!m.pending()); +} + +test "MouseFilter holds a drag across a read boundary" { + var m: MouseFilter = .{}; + var out: [64]u8 = undefined; + // A report split by the read: neither half may reach the board as loose bytes. + try std.testing.expectEqual(@as(usize, 0), m.feed("\x1b[<32;10", &out)); + try std.testing.expectEqual(@as(usize, 0), m.feed(";5M", &out)); + try std.testing.expect(m.pending()); + const flushed = m.flushHeld(&out); + try std.testing.expectEqualStrings("\x1b[<32;10;5M", out[0..flushed]); +} + +test "MouseFilter passes a non-mouse escape sequence through unchanged" { + var m: MouseFilter = .{}; + var out: [64]u8 = undefined; + // An arrow key and an in-band resize report both start with ESC and must survive intact. + const n = m.feed("\x1b[A\x1b[48;12;40;0;0t", &out); + try std.testing.expectEqualStrings("\x1b[A\x1b[48;12;40;0;0t", out[0..n]); + try std.testing.expect(!m.pending()); +} + +test "MouseFilter does not swallow a lone escape" { + var m: MouseFilter = .{}; + var out: [64]u8 = undefined; + // Esc is how you leave insert mode; holding it would be the worst possible bug here. + _ = m.feed("\x1b", &out); + const n = m.feed("x", &out); + try std.testing.expectEqualStrings("\x1bx", out[0..n]); +} -- cgit v1.3