From 3c981a1b9f3e1328e8ee4b0181d90b7c87f5061a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 17:14:21 -0300 Subject: A lone ESC read by itself is the Escape key at once, not after a 50 ms wait --- src/tty/tty.zig | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 01d2f08e..84552ddc 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -148,14 +148,19 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { if (scalar_len > end - (lead - 1)) parse_end = lead - 1; } var consumed: usize = 0; - var waited = false; + // A read that is one ESC and nothing else is the Escape key: a + // terminal writes each key's sequence whole and ssh carries each of + // its writes whole, so no sequence arrives cut right after its ESC. + // Waiting on it cost every Escape press 50 ms on a legacy terminal + // (tmux, most ssh sessions), and 1 s before kitty's flags were on. + var waited = carried == 0 and received == 1; while (consumed < parse_end) { - // A lone ESC that ends the read is the Escape key, or the first - // byte of a sequence whose rest the read boundary held back (an - // ssh packet, a busy link), which parsed now left the rest to be - // typed as text. Wait a moment for more. Under the kitty keyboard - // protocol Escape is `CSI 27 u`, so there the ESC can only start - // a sequence and the wait can be long. + // A lone ESC that ends a longer read is the Escape key, or the + // first byte of a sequence whose rest the read boundary held + // back (an ssh packet, a busy link), which parsed now left the + // rest to be typed as text. Wait a moment for more. Under the + // kitty keyboard protocol Escape is `CSI 27 u`, so there the ESC + // can only start a sequence and the wait can be long. const rest = buf[consumed..parse_end]; if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) { waited = true; @@ -455,6 +460,57 @@ test "terminal input split at every byte with a gap reads as it does whole" { vx.caps.kitty_keyboard = false; } +test "an ESC read by itself is the Escape key at once" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{}); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + // More always follows at once: a reader that waited on the ESC would + // take `j` for the rest of its sequence (Alt-j). + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + asked: bool = false, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), _: u32) bool { + self.asked = true; + return self.next < self.parts.len; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + for ([_]bool{ false, true }) |kitty| { + vx.caps.kitty_keyboard = kitty; + var reader: Reader = .{ .parts = &.{ "\x1b", "j" } }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(!reader.asked); + try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?)); + const escape = (try loop.tryEvent()).?.key_press; + try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint); + try std.testing.expect(!escape.mods.alt); + const j = (try loop.tryEvent()).?.key_press; + try std.testing.expectEqual(@as(u21, 'j'), j.codepoint); + try std.testing.expect(!j.mods.alt); + try std.testing.expect(try loop.tryEvent() == null); + } + vx.caps.kitty_keyboard = false; +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; -- cgit v1.3