summaryrefslogtreecommitdiff
path: root/src/tty/tty.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty/tty.zig')
-rw-r--r--src/tty/tty.zig296
1 files changed, 296 insertions, 0 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index a6f6bdfc..01d2f08e 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -148,10 +148,26 @@ 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;
+ // A modifier pressed on its own, which the kitty keyboard
+ // protocol reports: pardes binds none, it would break a pending
+ // chord, and a shell pane was typed its private-use code.
+ if (result.event) |event| if (event == .key_press and event.key_press.isModifier()) continue;
if (result.event) |event|
vaxis.loop.handleEventGeneric(loop, loop.vaxis, cache, @TypeOf(Command.value), event, loop.vaxis.opts.system_clipboard_allocator) catch |err| {
if (event == .paste) if (loop.vaxis.opts.system_clipboard_allocator) |gpa| gpa.free(@constCast(event.paste));
@@ -163,6 +179,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 +214,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];
@@ -243,6 +277,184 @@ test "terminal input preserves fragmented keys queries paste and text after EOF"
try std.testing.expect(resized and up and pasted and clipboard and escape);
}
+test "terminal replies and malformed sequences never become typed text" {
+ 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);
+ vx.queries_done.store(false, .unordered);
+ var tty: vaxis.Tty = undefined;
+ var loop: Loop = .init(io, &tty, &vx);
+ var cache: vaxis.GraphemeCache = .{};
+ const Reader = struct {
+ parts: []const []const u8,
+ next: usize = 0,
+ 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];
+ self.next += 1;
+ @memcpy(buf[0..part.len], part);
+ return part.len;
+ }
+ };
+ var reader: Reader = .{
+ .parts = &.{
+ // The startup width queries' answers where the terminal has no
+ // explicit width: both used to arrive as a bare F3, typed as text.
+ "a\x1b[1;1R\x1b[1;1R",
+ // A late report somewhere else on the screen, and DECXCPR.
+ "\x1b[12;40R\x1b[?3;7;1R",
+ // rxvt's Alt+Up: ESC before the sequence, not Alt+Esc then `[A`.
+ "b\x1b\x1b[A",
+ // A kitty graphics reply cut inside its ST sliced past its input.
+ "\x1b_Gi=1;OK\x1b",
+ "\\",
+ // An X10 mouse report cut after `CSI M` left three bytes as text.
+ "\x1b[M",
+ " !!",
+ // Malformed color and clipboard replies failed the parse, which
+ // ended input altogether; and a reply ended by BEL ran on to the
+ // next sequence's ESC, leaving what lay between as text.
+ "\x1b]11;rgb:zz/zz/zz\x1b\\\x1b]52;c;!!!!\x07\x1b]52\x07",
+ // Device reports whose fields end early.
+ "\x1b[?1;y\x1b[?997;n\x1b[c",
+ "\x1b[?62;c",
+ "c",
+ },
+ };
+ try readInput(&loop, &reader, &cache);
+ try std.testing.expect(vx.queries_done.load(.unordered));
+ try std.testing.expect(vx.caps.kitty_graphics);
+ var text: std.ArrayList(u8) = .empty;
+ defer text.deinit(gpa);
+ var alt_up = false;
+ var clicked = false;
+ while (try loop.tryEvent()) |event| switch (event) {
+ .winsize => {},
+ .key_press => |key| {
+ if (key.codepoint == vaxis.Key.up and key.mods.alt) {
+ alt_up = true;
+ } else if (key.text) |bytes| {
+ try text.appendSlice(gpa, bytes);
+ } else return error.UnexpectedInputKey;
+ },
+ .mouse => |mouse| {
+ try std.testing.expectEqual(@as(i16, 0), mouse.col);
+ clicked = true;
+ },
+ else => return error.UnexpectedInputEvent,
+ };
+ try std.testing.expectEqualStrings("abc", text.items);
+ 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;
@@ -262,6 +474,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) {
@@ -1726,6 +1941,24 @@ fn mapKey(cp: u21) u21 {
vaxis.Key.page_up => pardes.Key.page_up,
vaxis.Key.page_down => pardes.Key.page_down,
vaxis.Key.delete => pardes.Key.delete,
+ vaxis.Key.insert => pardes.Key.insert,
+ vaxis.Key.print_screen => pardes.Key.print_screen,
+ vaxis.Key.scroll_lock => pardes.Key.scroll_lock,
+ vaxis.Key.pause => pardes.Key.pause,
+ vaxis.Key.menu => pardes.Key.menu,
+ vaxis.Key.f1...vaxis.Key.f25 => pardes.Key.f1 + (cp - vaxis.Key.f1),
+ // The keypad's own keys, which the kitty keyboard protocol tells
+ // apart, do what the keys they are named for do.
+ vaxis.Key.kp_left => pardes.Key.left,
+ vaxis.Key.kp_right => pardes.Key.right,
+ vaxis.Key.kp_up => pardes.Key.up,
+ vaxis.Key.kp_down => pardes.Key.down,
+ vaxis.Key.kp_page_up => pardes.Key.page_up,
+ vaxis.Key.kp_page_down => pardes.Key.page_down,
+ vaxis.Key.kp_home => pardes.Key.home,
+ vaxis.Key.kp_end => pardes.Key.end,
+ vaxis.Key.kp_insert => pardes.Key.insert,
+ vaxis.Key.kp_delete => pardes.Key.delete,
else => cp,
};
}
@@ -1751,6 +1984,69 @@ fn keyEvent(key: vaxis.Key) pardes.Event {
} };
}
+test "a terminal pane gets function keys as their sequences and lone modifiers not at all" {
+ 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);
+ vx.queries_done.store(false, .unordered);
+ var tty: vaxis.Tty = undefined;
+ var loop: Loop = .init(io, &tty, &vx);
+ var cache: vaxis.GraphemeCache = .{};
+ const Reader = struct {
+ parts: []const []const u8,
+ next: usize = 0,
+ 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];
+ self.next += 1;
+ @memcpy(buf[0..part.len], part);
+ return part.len;
+ }
+ };
+ var reader: Reader = .{
+ .parts = &.{
+ // The startup width queries' answers from a terminal without
+ // explicit width (foot, ghostty, tmux): two F3s, typed at the prompt.
+ "\x1b[1;1R\x1b[1;1R\x1b[?62;c",
+ // Kitty reports Ctrl and Shift pressed on their own: the Ctrl of a
+ // Ctrl-V typed `\x1b[57442;5u` into the shell.
+ "\x1b[57442;5u\x1b[57441;2u\x1b[57358u",
+ // F3, F5 and Insert the kitty way, F3 the legacy way and the
+ // keypad's Up: once typed as private-use junk, now what htop,
+ // mc and vim read them as.
+ "\x1b[13~\x1b[15~\x1b[2~\x1bOR\x1b[57419u",
+ "\x1b[104;;104u",
+ },
+ };
+ try readInput(&loop, &reader, &cache);
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ core.panes[0].?.body.mode = .tty;
+ while (core.nextEffect()) |_| {}
+ while (try loop.tryEvent()) |event| switch (event) {
+ .key_press => |key| core.update(keyEvent(key)),
+ else => {},
+ };
+ var written: std.Io.Writer.Allocating = .init(gpa);
+ defer written.deinit();
+ while (core.nextEffect()) |effect| {
+ if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice());
+ }
+ try std.testing.expectEqualStrings("\x1bOR\x1b[15~\x1b[2~\x1bOR\x1b[Ah", written.written());
+}
+
test "keypad Enter matches Return for editor input terminal writes and paste" {
for ([_]u21{ vaxis.Key.enter, vaxis.Key.kp_enter }) |codepoint| {
const key: vaxis.Key = .{ .codepoint = codepoint };