From 2d3148247e6555b7b33bd532ae538d7a4358e160 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 01:42:15 -0300 Subject: Hold a lone ESC: every escape sequence on this wire was being shredded The mouse did not work. Chasing that found something much larger: NO escape sequence worked on this transport, and had not since the port began. `vaxis.Parser` resolves a buffer containing nothing but 0x1b as the Escape KEY. That is deliberate and correct for a terminal, where the kernel hands over a whole escape sequence in a single read, so a solitary ESC really does mean somebody pressed Escape. A 115200 serial line hands over ONE BYTE AT A TIME - 87 us apart, an eternity to a loop running at 360 MHz - so the first byte of every sequence arrived alone and was resolved as Escape, and the remaining bytes arrived as ordinary keys. A mouse click therefore came through as TEN key presses: Escape, `[`, `<`, `0`, `;`, `1`, `8`, `;`, `3`, `M`. The `0` among them is "go to column zero" in normal mode, which is exactly where the cursor kept landing, and why the first attempt at this looked like a coordinate bug. Arrow keys, function keys, and the host bridge's in-band resize reports were all being taken apart the same way. Longer partial sequences were never affected: the CSI scanner returns `n == 0` for "no final byte yet" and the shell already keeps those bytes. Only the one-byte case needed an answer, because it is the only one the parser answers WRONGLY instead of declining. So the shell holds a buffer that is exactly one ESC and lets `pardes_p4_tick` release it after 10 ms - two orders of magnitude longer than the 87 us until the next byte of a real sequence, and imperceptible to a person pressing Escape. The same trade every terminal editor makes, for the same reason. Finding it took instrumenting the ABI: printing `@tagName` of every event the shell applied. Ten `key_press` where one `mouse` belonged is not a thing any amount of reading the coordinate arithmetic would have shown, and I had already read it twice. ## Mouse reporting, and the 1003 that is not requested With the sequences intact, `apply` already handled `.mouse` - it mirrors the tty shell - so enabling reporting was the only missing piece. Spelled out here rather than taken from `vx.setMouseMode`, which asks for `1002;1003;1004;1006`: 1003 is ANY-MOTION tracking, a report per cell the pointer crosses with no button held. On a 115200 line that is dozens of 15-byte reports for one sweep, arriving as input the editor must parse while it paints, and arriving whether or not anyone wants it - moving the mouse over the window would starve typing. 1002 reports presses, releases and motion while a button is held, which is exactly what a click and a drag-select need. Verified on the die: a click at column 12 puts the cursor at column 12 and one at column 22 puts it at column 22, a drag paints a selection, and the wheel scrolls. A press alone paints the new position and then reverts - the caret does not move until the gesture ends - so the release is what commits it, which cost an hour of believing a working click was broken. Screen byte-identical to the vaxis reference, round trip median 3682 us against 3682, snap 95/95, hxdiff 481/0, hxparity 561/0, unit-test, tty/p4/gui all build. --- src/p4.zig | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) (limited to 'src/p4.zig') diff --git a/src/p4.zig b/src/p4.zig index bf9d1c7d..cb8a5790 100644 --- a/src/p4.zig +++ b/src/p4.zig @@ -259,6 +259,21 @@ export fn pardes_p4_init( // works over a serial line: the replies arrive as ordinary input and are parsed like any key. vx.enterAltScreen(&out) catch |err| return errCode(err); vx.queryTerminalSend(&out) catch |err| return errCode(err); + + // MOUSE REPORTING, spelled out here rather than taken from `vx.setMouseMode`. + // + // vaxis enables `1002;1003;1004;1006`, and 1003 is ANY-MOTION tracking: the terminal reports + // every cell the pointer crosses with no button held. On a 115200 line that is unaffordable - + // one sweep across this grid is dozens of reports of ~15 bytes each, and each one arrives as + // input that the editor must parse while it is trying to paint. Worse, it arrives whether or not + // anybody wants it, so moving the mouse over the window would starve typing. + // + // 1002 reports presses, releases and motion WHILE A BUTTON IS HELD, which is exactly the set a + // click and a drag-select need. 1004 is focus in/out, which `apply` already handles. 1006 is the + // SGR encoding: unlike the original X10 form it is not limited to column 223, which a grid this + // small does not need today but costs nothing to have and cannot be added later without the + // terminal disagreeing with the editor about where the pointer is. + out.writeAll("\x1b[?1002;1004;1006h") catch |err| return errCode(err); out.flush() catch |err| return errCode(err); // The CLAMPED geometry, because the core and vaxis must agree on the grid and vaxis was just @@ -293,8 +308,33 @@ export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void { in_len += take; } + drainInput(c, false); +} + +/// Parse what has accumulated, applying every event it yields. +/// +/// THE LONE ESCAPE IS AMBIGUOUS, and on this transport it is ambiguous constantly. `vaxis.Parser` +/// resolves a buffer containing nothing but `0x1b` as the Escape KEY - deliberately, and correctly +/// for a real terminal, where the kernel hands over a whole escape sequence in one read so a solitary +/// ESC really does mean the key. A 115200 serial line hands over one byte at a time: 87 us apart, +/// which is an eternity to this loop. So the first byte of EVERY escape sequence arrived alone and +/// was resolved as Escape, and the rest arrived as ordinary keys. +/// +/// That is not a mouse bug, though it is why the mouse did not work: a click report came through as +/// ten key presses - Escape, `[`, `<`, `0`, ... - and the `0` among them is "go to column zero" in +/// normal mode, which is exactly where the cursor kept landing. Arrow keys, function keys and the +/// host's in-band resize reports were all being shredded the same way. +/// +/// Longer partial sequences were never affected: the CSI scanner returns `n == 0` for "no final byte +/// yet", and the loop below keeps those bytes. Only the one-byte case needed an answer, because it is +/// the only one the parser answers wrongly instead of declining. +/// +/// `force` is how a real Escape keypress still works: `pardes_p4_tick` calls with it set once the +/// hold has lasted longer than any serial line would take to deliver the next byte. +fn drainInput(c: *pardes.Pardes, force: bool) void { var off: usize = 0; while (off < in_len) { + if (!force and in_len - off == 1 and in_buf[off] == 0x1b) break; const res = parser.parse(in_buf[off..in_len], gpa()) catch break; if (res.n == 0) break; // incomplete: wait for more bytes off += res.n; @@ -305,6 +345,11 @@ export fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void { std.mem.copyForwards(u8, in_buf[0 .. in_len - off], in_buf[off..in_len]); in_len -= off; } + // Start or clear the hold. `esc_held_at` is only ever set for a buffer that is exactly one ESC, + // so a partial CSI - which the parser already declines - does not start a timer it does not need. + if (in_len == 1 and in_buf[0] == 0x1b) { + if (esc_held_at == null) esc_held_at = last_now_ms; + } else esc_held_at = null; } /// One parsed vaxis event applied to the core. Mirrors the tty shell's `apply` @@ -475,16 +520,36 @@ fn mapKey(cp: u21) u21 { else => cp, }; } - export fn pardes_p4_tick(now_ms: u64) callconv(.c) void { const c = core orelse return; - _ = now_ms; + last_now_ms = now_ms; + // The held Escape, released. Anything still waiting after this long is a key the human pressed, + // not the head of a sequence: the next byte of a real sequence is 87 us behind on this line, and + // even a slow terminal emulator answers a query in well under a millisecond. Ten is generous by + // two orders of magnitude and imperceptible to the person pressing it - the same trade every + // terminal editor makes for the same reason. + if (esc_held_at) |at| { + if (now_ms -% at >= esc_hold_ms) { + drainInput(c, true); + dirty = true; + } + } if (c.animationActive()) { c.update(.tick); dirty = true; } } +/// How long a lone ESC waits for a second byte before it counts as the Escape key. +const esc_hold_ms = 10; + +/// The last timestamp `pardes_p4_tick` was given, so `drainInput` can date a hold without needing a +/// clock of its own - there is no clock on this side of the ABI. +var last_now_ms: u64 = 0; + +/// When the buffer became a lone ESC, or null when it is not holding one. +var esc_held_at: ?u64 = null; + export fn pardes_p4_wants_frame() callconv(.c) bool { const c = core orelse return false; return dirty or c.animationActive(); -- cgit v1.3