diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 14:14:47 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | 1f929613ad10a525fc2c63d399eda9b2827e1bae (patch) | |
| tree | 104537a0ecda9f67560c33b56f1f8e0105279574 /src/tty/tty.zig | |
| parent | f2c0c6f1aa90a567dc9d37c5eb8f53ece58cff88 (diff) | |
| download | pardes-1f929613ad10a525fc2c63d399eda9b2827e1bae.tar.gz pardes-1f929613ad10a525fc2c63d399eda9b2827e1bae.zip | |
A lone ESC that ends a read waits for the rest of its sequence
Diffstat (limited to 'src/tty/tty.zig')
| -rw-r--r-- | src/tty/tty.zig | 132 |
1 files changed, 132 insertions, 0 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 8992b51b..acd75e0a 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -148,7 +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; 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. + 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; + if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break; + } const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator); if (result.n == 0) break; consumed += result.n; @@ -163,6 +175,21 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { } } +/// Whether more input is readable within `ms`, polled in short slices so +/// that a cancel of the input thread is still seen. A test reader answers +/// for itself. +fn inputFollows(tty: anytype, io: std.Io, ms: u32) !bool { + if (@hasDecl(@TypeOf(tty.*), "inputFollows")) return tty.inputFollows(ms); + const handle = if (@TypeOf(tty.fd) == std.Io.File) tty.fd.handle else tty.fd; + var fds = [_]posix.pollfd{.{ .fd = handle, .events = posix.POLL.IN, .revents = 0 }}; + var left = ms; + while (left > 0) : (left -|= 10) { + try io.checkCancel(); + if (try posix.poll(&fds, @intCast(@min(left, 10))) > 0) return true; + } + return false; +} + test "terminal input preserves fragmented keys queries paste and text after EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -183,6 +210,9 @@ test "terminal input preserves fragmented keys queries paste and text after EOF" fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } + fn inputFollows(self: *@This(), _: u32) bool { + 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]; @@ -263,6 +293,9 @@ test "terminal replies and malformed sequences never become typed text" { fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } + fn inputFollows(self: *@This(), _: u32) bool { + 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]; @@ -322,6 +355,102 @@ test "terminal replies and malformed sequences never become typed text" { try std.testing.expect(alt_up and clicked); } +test "terminal input split at every byte with a gap reads as it does whole" { + 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, .{ .system_clipboard_allocator = gpa }); + 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 = .{}; + // Two reads with `gap_ms` between them, as an ssh link delivers a + // sequence cut across two packets. + const Reader = struct { + parts: [2][]const u8, + gap_ms: u32, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), ms: u32) bool { + return self.next < self.parts.len and self.gap_ms <= ms; + } + fn read(self: *@This(), buf: []u8) !usize { + while (self.next < self.parts.len) { + const part = self.parts[self.next]; + self.next += 1; + if (part.len == 0) continue; + @memcpy(buf[0..part.len], part); + return part.len; + } + return 0; + } + /// What the loop was handed, one line per event. + fn events(l: *Loop, out: *std.ArrayList(u8)) !void { + out.clearRetainingCapacity(); + while (try l.tryEvent()) |event| switch (event) { + .winsize => {}, + .key_press => |k| try out.print(std.testing.allocator, "key {d} {d} {?s}\n", .{ k.codepoint, @as(u8, @bitCast(k.mods)), k.text }), + .mouse => |m| try out.print(std.testing.allocator, "mouse {d} {d} {t} {t}\n", .{ m.col, m.row, m.button, m.type }), + .paste => |bytes| { + try out.print(std.testing.allocator, "paste {s}\n", .{bytes}); + std.testing.allocator.free(@constCast(bytes)); + }, + else => |other| try out.print(std.testing.allocator, "{t}\n", .{other}), + }; + } + }; + const Case = struct { bytes: []const u8, kitty: bool = false }; + const cases = [_]Case{ + .{ .bytes = "\x1b[118;5u", .kitty = true }, // Ctrl-V + .{ .bytes = "\x1b[97;;97u", .kitty = true }, // a, with its text + .{ .bytes = "\x1b[57442;5u", .kitty = true }, // Ctrl on its own + .{ .bytes = "\x1b[27u", .kitty = true }, // Escape + .{ .bytes = "\x1b[A" }, + .{ .bytes = "\x1bOA" }, + .{ .bytes = "\x1b[3;5~" }, + .{ .bytes = "\x1b\x1b[A" }, + // Four wheel notches: cut at a `;` with 60 ms between the halves, + // ssh's framing lost all four. + .{ .bytes = "\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M" }, + .{ .bytes = "\x1b[200~pasted\x1b[201~" }, + .{ .bytes = "\x1b]52;c;Y2xpcA==\x1b\\" }, + .{ .bytes = "\x1b_Gi=1;OK\x1b\\" }, + .{ .bytes = "\x1bP>|kitty(0.40)\x1b\\" }, + }; + var whole: std.ArrayList(u8) = .empty; + defer whole.deinit(gpa); + var split: std.ArrayList(u8) = .empty; + defer split.deinit(gpa); + for (cases) |case| { + vx.caps.kitty_keyboard = case.kitty; + const all = try std.mem.concat(gpa, u8, &.{ "x", case.bytes, "y" }); + defer gpa.free(all); + var one: Reader = .{ .parts = .{ all, "" }, .gap_ms = 0 }; + try readInput(&loop, &one, &cache); + try Reader.events(&loop, &whole); + for (1..all.len) |cut| { + // A cut just after an ESC is the one that has to wait, and a + // legacy terminal's Escape waits only 50 ms: 40 ms there. A cut + // anywhere else is carried however long the gap. + const after_esc = all[cut - 1] == 0x1b; + var two: Reader = .{ .parts = .{ all[0..cut], all[cut..] }, .gap_ms = if (after_esc and !case.kitty) 40 else 60 }; + try readInput(&loop, &two, &cache); + try Reader.events(&loop, &split); + std.testing.expectEqualStrings(whole.items, split.items) catch |err| { + std.debug.print("case {f} cut at {d}\n", .{ std.ascii.hexEscape(case.bytes, .lower), cut }); + return err; + }; + } + } + 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; @@ -341,6 +470,9 @@ test "terminal input cancellation joins blocked reads and queued EOF" { fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } + fn inputFollows(_: *@This(), _: u32) bool { + return false; + } fn read(self: *@This(), buf: []u8) !usize { self.entered.set(std.testing.io); if (self.eof) { |
