diff options
| -rw-r--r-- | build.zig | 188 | ||||
| -rw-r--r-- | docs/config.md | 4 | ||||
| -rw-r--r-- | src/Terminal.zig | 17 | ||||
| -rw-r--r-- | src/pardes.zig | 8 | ||||
| -rw-r--r-- | src/tty/tty.zig | 296 |
5 files changed, 509 insertions, 4 deletions
@@ -682,11 +682,195 @@ pub fn build(b: *std.Build) void { .tables_path = uucode_tables, }).module("uucode"); - const vaxis_mod = b.dependency("vaxis", .{ + const vaxis_dep = b.dependency("vaxis", .{ .target = target, .optimize = optimize, .external_uucode = true, - }).module("vaxis"); + }); + const vaxis_mod = vaxis_dep.module("vaxis"); + // vaxis's input parser, patched here at build time like mvzr so that a + // fresh fetch keeps it: no reply, split read or malformed sequence may + // reach the editor as typed text, and none may panic or end input. + // - A cursor position report that no F3 could have sent is no key: the + // startup width queries' `CSI 1;1R` arrived as two F3s, which a shell + // pane took for text. + // - ESC before a CSI or SS3 key (rxvt's Alt+arrow) is Alt on that key, + // not Alt+Escape followed by `[A` as text. + // - An X10 mouse report and a kitty graphics reply cut short by a read + // wait for their rest: the first became three bytes of text, the + // second sliced past its input. + // - A malformed color or clipboard reply is dropped instead of failing + // the parse, which stopped input; so is a byte no UTF-8 starts with. + // - An OSC reply ends at its first terminator: one ended by BEL ran on to + // the next sequence's ESC and left what lay between as text. + // - A device report's fields are sought in the sequence, not past it. + // A bump that moves an anchor stops the build. + { + const parser_path = vaxis_dep.path("src/Parser.zig").getPath(b); + var src = std.Io.Dir.cwd().readFileAlloc(io, parser_path, b.allocator, .limited(1 << 20)) catch @panic("read vaxis Parser.zig"); + const edits = [_]struct { anchor: []const u8, with: []const u8, count: usize = 1 }{ + .{ + .anchor = + \\ 0x5F => return parseApc(input), + \\ else => { + , + .with = + \\ 0x5F => return parseApc(input), + \\ // pardes's patch (its build.zig): ESC before a key's sequence is Alt on the key. + \\ 0x1B => if (input.len > 2 and (input[2] == '[' or input[2] == 'O')) { + \\ var inner = if (input[2] == '[') parseCsi(input[1..], &self.buf) else parseSs3(input[1..]); + \\ if (inner.n == 0) return inner; + \\ if (inner.event) |*event| switch (event.*) { + \\ .key_press, .key_release => |*key| key.mods.alt = true, + \\ else => {}, + \\ }; + \\ inner.n += 1; + \\ return inner; + \\ } else return .{ .event = .{ .key_press = .{ .codepoint = Key.escape, .mods = .{ .alt = true } } }, .n = 2 }, + \\ else => { + , + }, + .{ + .anchor = " const first_cp = iter.next() orelse return error.InvalidUTF8;\n", + .with = " const first_cp = iter.next() orelse return .{ .event = null, .n = 1 }; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = " n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return error.InvalidUTF8;\n", + .with = " n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return .{ .event = null, .n = 1 }; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = + \\ const sequence = input[0 .. end + 1 + 1]; + \\ + \\ switch (input[2]) { + , + .with = + \\ // pardes's patch (its build.zig): an ESC that ends the input is half an ST. + \\ if (input.len < end + 2) return .{ .event = null, .n = 0 }; + \\ const sequence = input[0 .. end + 1 + 1]; + \\ + \\ switch (input[2]) { + , + }, + .{ + .anchor = + \\ const esc_result = skipUntilST(input); + \\ if (esc_result.n > 0) break :blk esc_result.n; + \\ + \\ // No escape, could be BEL terminated + \\ const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07) orelse return .{ + \\ .event = null, + \\ .n = 0, + \\ }; + \\ bel_terminated = true; + \\ break :blk bel + 1; + , + .with = + \\ // pardes's patch (its build.zig): whichever terminator comes first ends it. + \\ const esc = std.mem.indexOfScalarPos(u8, input, 2, 0x1b); + \\ const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07); + \\ if (bel != null and (esc == null or bel.? < esc.?)) { + \\ bel_terminated = true; + \\ break :blk bel.? + 1; + \\ } + \\ const esc_result = skipUntilST(input); + \\ if (esc_result.n > 0) break :blk esc_result.n; + \\ return .{ .event = null, .n = 0 }; + , + }, + .{ + .anchor = " const color = try Color.rgbFromSpec(color_spec);\n", + .with = " const color = Color.rgbFromSpec(color_spec) catch return null_event; // pardes's patch (its build.zig)\n", + .count = 2, + }, + .{ + .anchor = + \\ if (input[semicolon_idx + 1] != 'c') return null_event; + \\ const payload = if (bel_terminated) + \\ input[semicolon_idx + 3 .. sequence.len - 1] + \\ else + \\ input[semicolon_idx + 3 .. sequence.len - 2]; + \\ const decoder = std.base64.standard.Decoder; + \\ const text = try paste_allocator.?.alloc(u8, try decoder.calcSizeForSlice(payload)); + \\ try decoder.decode(text, payload); + , + .with = + \\ // pardes's patch (its build.zig): a malformed reply is dropped. + \\ const body_end = if (bel_terminated) sequence.len - 1 else sequence.len - 2; + \\ if (semicolon_idx + 3 > body_end or input[semicolon_idx + 1] != 'c') return null_event; + \\ const payload = input[semicolon_idx + 3 .. body_end]; + \\ const decoder = std.base64.standard.Decoder; + \\ const gpa = paste_allocator orelse return null_event; + \\ const text = gpa.alloc(u8, decoder.calcSizeForSlice(payload) catch return null_event) catch return null_event; + \\ decoder.decode(text, payload) catch { + \\ gpa.free(text); + \\ return null_event; + \\ }; + , + }, + .{ + .anchor = + \\ 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + \\ + , + .with = + \\ 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + \\ // pardes's patch (its build.zig): a cursor position report is no key. + \\ if (final == 'R' and cursorReport(sequence)) return null_event; + \\ + , + }, + .{ + .anchor = " std.debug.assert(sequence.len >= 4); // ESC [ ? c == 4 bytes\n", + .with = " if (sequence.len < 4) return null_event; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = "const delim_idx = std.mem.indexOfScalarPos(u8, input, 3, ';')", + .with = "const delim_idx = std.mem.indexOfScalarPos(u8, sequence, 3, ';')", + .count = 2, + }, + .{ + .anchor = "input[delim_idx + 1 .. sequence.len - 2]", + .with = "sequence[@min(delim_idx + 1, sequence.len - 2) .. sequence.len - 2]", + }, + .{ + .anchor = " if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) {\n", + .with = + \\ // pardes's patch (its build.zig): an X10 report still arriving is incomplete, not text. + \\ if (input.len == 3 and input[2] == 'M' and full_input.len < 6) return .{ .event = null, .n = 0 }; + \\ if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) { + \\ + , + }, + }; + for (edits) |edit| { + if (std.mem.count(u8, src, edit.anchor) != edit.count) std.debug.panic("vaxis's Parser.zig changed at `{s}`: redo its patch in build.zig", .{edit.anchor}); + src = std.mem.replaceOwned(u8, b.allocator, src, edit.anchor, edit.with) catch @panic("OOM"); + } + const cursor_report = + \\ + \\/// pardes's patch (its build.zig): whether `CSI ... R` answers a cursor + \\/// position query. Only `CSI 1 ; mods R` with mods past 1 is a modified F3 + \\/// (a bare F3 is `SS3 R`); a report has another row, or col 1 (mods 1). + \\fn cursorReport(sequence: []const u8) bool { + \\ const params = sequence[2 .. sequence.len - 1]; + \\ if (params.len > 0 and params[0] == '?') return true; + \\ var fields = std.mem.splitScalar(u8, params, ';'); + \\ const row = fields.first(); + \\ const col = fields.next() orelse return false; + \\ if (fields.next() != null or row.len == 0 or col.len == 0) return false; + \\ for (params) |c| if (c != ';' and !std.ascii.isDigit(c)) return false; + \\ return !std.mem.eql(u8, row, "1") or std.mem.eql(u8, col, "1"); + \\} + \\ + ; + const files = b.addWriteFiles(); + _ = files.add("Parser.zig", b.fmt("{s}{s}", .{ src, cursor_report })); + _ = files.addCopyFile(vaxis_dep.path("src/widgets/terminal/Parser.zig"), "widgets/terminal/Parser.zig"); + // Suffixes: this leaves out both Parser.zig files, written above. + _ = files.addCopyDirectory(vaxis_dep.path("src"), "", .{ .exclude_extensions = &.{"Parser.zig"} }); + vaxis_mod.root_source_file = files.getDirectory().path(b, "main.zig"); + } var ghostty_vt_for_snap: ?*std.Build.Module = null; const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin()); diff --git a/docs/config.md b/docs/config.md index 1af696be..349814a6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -554,7 +554,9 @@ keeps the last good one and says why), and GUI attached to a detached session runs the session's chain the same way. With the chain empty the pass is bypassed. CRT works in linear light with restrained scanlines, mask, bloom and vignette, and no curvature, so clicks land where -they are drawn. +they are drawn. Its slow hum and dither move on their own, so with Crt on the +GUI keeps drawing while idle (under `ShaderAnimation on`, while focused); +the other bundled passes are still and let it rest. Three more bundled passes take the same levels, each off by default and each costing nothing while off: diff --git a/src/Terminal.zig b/src/Terminal.zig index 32595d25..c99a92a6 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -1113,6 +1113,11 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { const pane = p.panes[id] orelse return; const state = pane.terminal orelse return; const input = ghostty_vt.input; + const function_keys = comptime keys: { + var keys: [Key.f25 - Key.f1 + 1]input.Key = undefined; + for (&keys, 1..) |*k, n| k.* = @field(input.Key, std.fmt.comptimePrint("f{d}", .{n})); + break :keys keys; + }; const physical: input.Key = switch (key.cp) { Key.enter => .enter, Key.backspace => .backspace, @@ -1127,11 +1132,21 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { Key.page_up => .page_up, Key.page_down => .page_down, Key.delete => .delete, + Key.insert => .insert, + Key.print_screen => .print_screen, + Key.scroll_lock => .scroll_lock, + Key.pause => .pause, + Key.menu => .context_menu, + Key.f1...Key.f25 => function_keys[key.cp - Key.f1], else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified, }; var utf8: [4]u8 = undefined; const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp; - const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace) + // A key with no text of its own types its code point only when that + // is a character: the kitty keyboard protocol numbers function and + // modifier keys in the private use area (F3 is U+E016), and a shell + // was typed those as junk. + const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace and !(key.cp >= 0xE000 and key.cp <= 0xF8FF)) utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] else ""; diff --git a/src/pardes.zig b/src/pardes.zig index 4bc5829b..dc479909 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3600,6 +3600,14 @@ pub const Key = struct { pub const page_up: u21 = 0xF0007; pub const page_down: u21 = 0xF0008; pub const delete: u21 = 0xF0009; + pub const insert: u21 = 0xF000A; + pub const print_screen: u21 = 0xF000B; + pub const scroll_lock: u21 = 0xF000C; + pub const pause: u21 = 0xF000D; + pub const menu: u21 = 0xF000E; + /// F1 to F25, in order: Fn is `f1 + n - 1`. + pub const f1: u21 = 0xF0011; + pub const f25: u21 = f1 + 24; }; pub fn hit(key: Key, binding: []const config.Chord) bool { 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 }; |
