summaryrefslogtreecommitdiff
path: root/src/tty
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty')
-rw-r--r--src/tty/tty.zig132
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) {