diff options
Diffstat (limited to 'tools/console.zig')
| -rw-r--r-- | tools/console.zig | 263 |
1 files changed, 257 insertions, 6 deletions
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[<b;x;yM` this will accept. Generous for a 5-digit coordinate each way; anything + /// longer is not a mouse report and is passed through as ordinary bytes. + const max_report = 24; + + /// Split `in` into bytes to send now and, possibly, one motion report to hold. + /// + /// Returns the number of bytes written to `out`, which is never more than `in.len` plus whatever + /// a previously held report contributes. + fn feed(m: *MouseFilter, in: []const u8, out: []u8) usize { + var n: usize = 0; + var i: usize = 0; + while (i < in.len) { + // Continue a report that straddled the previous read. + if (m.partial_len > 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]); +} |
