diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/input/key_encode.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/input/key_encode.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/input/key_encode.zig | 428 |
1 files changed, 366 insertions, 62 deletions
diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig index 6ab5a4cc..53a7deaa 100644 --- a/vendor/libghostty-vt/src/input/key_encode.zig +++ b/vendor/libghostty-vt/src/input/key_encode.zig @@ -224,9 +224,11 @@ fn kitty( const entry = entry_ orelse { // No entry found. If we have UTF-8 text this is a pure text event // (e.g. composed/IME text), so send it as-is so programs can - // still receive it. - if (event.utf8.len > 0) return try writer.writeAll(event.utf8); - return; + // still receive it. Release events never insert text, same as the + // plain-text path above. + if (event.action == .release) return; + if (event.utf8.len == 0) return; + return try writer.writeAll(event.utf8); }; // If this is just a modifier we require "report all" to send the sequence. @@ -377,42 +379,10 @@ fn legacy( return try writer.writeAll(sequence); } - // If we match a control sequence, we output that directly. For - // ctrlSeq we have to use all mods because we want it to only - // match ctrl+<char>. - if (ctrlSeq( - event.key, - event.utf8, - event.unshifted_codepoint, - all_mods, - )) |char| { - // C0 sequences support alt-as-esc prefixing. - if (binding_mods.alt) { - try writer.writeByte(0x1B); - try writer.writeByte(char); - return; - } - - try writer.writeByte(char); - return; - } - - // If we have no UTF8 text then the only possibility is the - // alt-prefix handling of unshifted codepoints... so we process that. - const utf8 = event.utf8; - if (utf8.len == 0) { - if (try legacyAltPrefix( - event, - binding_mods, - all_mods, - opts, - )) |byte| try writer.print("\x1B{c}", .{byte}); - return; - } - // In modify other keys state 2, we send the CSI 27 sequence - // for any char with a modifier. Ctrl sequences like Ctrl+a - // are already handled above. + // for any char with a modifier. This must happen before converting + // Ctrl+<char> to a C0 byte because mode 2 encodes those keys, too. + const utf8 = event.utf8; if (opts.modify_other_keys_state_2) modify_other: { const view = std.unicode.Utf8View.init(utf8) catch { // Assume invalid UTF-8 means we no UTF-8. @@ -474,6 +444,39 @@ fn legacy( } } + // If we match a control sequence, we output that directly. For + // ctrlSeq we have to use all mods because we want it to only + // match ctrl+<char>. + if (ctrlSeq( + event.key, + event.utf8, + event.unshifted_codepoint, + all_mods, + )) |char| { + // C0 sequences support alt-as-esc prefixing. + if (binding_mods.alt) { + try writer.writeByte(0x1B); + try writer.writeByte(char); + return; + } + + try writer.writeByte(char); + return; + } + + // If we have no UTF8 text then the only possibility is the + // alt-prefix handling of unshifted codepoints... so we process that. + if (utf8.len == 0) { + _ = try legacyAltPrefix( + writer, + event, + binding_mods, + all_mods, + opts, + ); + return; + } + // Let's see if we should apply fixterms to this codepoint. // At this stage of key processing, we only need to apply fixterms // to unicode codepoints if we have ctrl set. @@ -523,13 +526,12 @@ fn legacy( // If we have alt-pressed and alt-esc-prefix is enabled, then // we need to prefix the utf8 sequence with an esc. if (try legacyAltPrefix( + writer, event, binding_mods, all_mods, opts, - )) |byte| { - return try writer.print("\x1B{c}", .{byte}); - } + )) return; // If we are on macOS, command+keys do not encode text. It isn't // typical for command+keys on macOS to ever encode text. They @@ -548,47 +550,71 @@ fn legacy( } fn legacyAltPrefix( + writer: *std.Io.Writer, event: key.KeyEvent, binding_mods: key.Mods, mods: key.Mods, opts: Options, -) !?u8 { +) std.Io.Writer.Error!bool { // This only takes effect with alt pressed - if (!binding_mods.alt or !opts.alt_esc_prefix) return null; + if (!binding_mods.alt or !opts.alt_esc_prefix) return false; // On macOS, we only handle option like alt in certain // circumstances. Otherwise, macOS does a unicode translation // and we allow that to happen. if (comptime builtin.os.tag == .macos) { switch (opts.macos_option_as_alt) { - .false => return null, - .left => if (mods.sides.alt == .right) return null, - .right => if (mods.sides.alt == .left) return null, + .false => return false, + .left => if (mods.sides.alt == .right) return false, + .right => if (mods.sides.alt == .left) return false, .true => {}, } } - // Otherwise, we require utf8 to already have the byte represented. + // A single byte is already the exact text we want to prefix. In + // particular, this preserves shifted ASCII punctuation. const utf8 = event.utf8; if (utf8.len == 1) { - if (std.math.cast(u8, utf8[0])) |byte| { - return byte; - } + try writer.writeByte(0x1B); + try writer.writeAll(utf8); + return true; } - // If UTF8 isn't set, we will allow unshifted codepoints through. - if (event.unshifted_codepoint > 0) { - if (std.math.cast( - u8, - event.unshifted_codepoint, - )) |byte| { - return byte; + var unshifted_buf: [4]u8 = undefined; + const value: []const u8 = value: { + // On macOS, Option may translate the text into a different Unicode + // value. When Option is configured as Alt, use the physical key's + // unshifted codepoint just as the single-byte implementation did. + if (comptime builtin.os.tag == .macos) { + if (event.unshifted_codepoint > 0) { + const len = std.unicode.utf8Encode( + event.unshifted_codepoint, + &unshifted_buf, + ) catch return false; + break :value unshifted_buf[0..len]; + } } - } - // Else, we can't figure out the byte to alt-prefix so we - // exit this handling. - return null; + // Outside of the macOS translation case, prefix the complete UTF-8 + // text rather than truncating it to a single byte. + if (utf8.len > 0) break :value utf8; + + // Frontends may omit UTF-8 while still supplying the physical key's + // unshifted codepoint. Encode the complete scalar in that case. + if (event.unshifted_codepoint > 0) { + const len = std.unicode.utf8Encode( + event.unshifted_codepoint, + &unshifted_buf, + ) catch return false; + break :value unshifted_buf[0..len]; + } + + return false; + }; + + try writer.writeByte(0x1B); + try writer.writeAll(value); + return true; } /// A helper to memcpy a src value to a buffer and return the result. @@ -1357,6 +1383,22 @@ test "kitty: shift+backspace emits CSI u" { try testing.expectEqualStrings("\x1b[127;2u", writer.buffered()); } +test "kitty: alt+backspace emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .mods = .{ .alt = true }, + // macOS may mark Option as consumed while translating the key. With + // no attached control text, all modifiers must remain effective. + .consumed_mods = .{ .alt = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[127;3u", writer.buffered()); +} + test "kitty: shift+enter emits CSI u" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); @@ -1492,6 +1534,50 @@ test "kitty: composed text with report all" { try testing.expectEqualStrings("\xc3\xbb", writer.buffered()); } +// A key whose base (unshifted) keysym is a dead key has no unshifted +// codepoint, so it has no kitty entry and falls back to writing the text +// directly. That must not happen on release or the text is inserted twice. +test "kitty: text fallback on release" { + for ([_]bool{ false, true }) |report_all| { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .unidentified, + .mods = .{ .shift = true }, + .utf8 = "!", + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = report_all, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("", writer.buffered()); + } +} + +test "kitty: text fallback on repeat" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .repeat, + .key = .unidentified, + .mods = .{ .shift = true }, + .utf8 = "!", + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("!", writer.buffered()); +} + test "kitty: shift+a on US keyboard" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); @@ -1971,6 +2057,28 @@ test "legacy: esc with utf8 (dead key state)" { try testing.expectEqualStrings("A", writer.buffered()); } +test "legacy: alt+escape with modify other state 2" { + var buf: [128]u8 = undefined; + + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .escape, + .mods = .{ .alt = true }, + }, .{}); + try testing.expectEqualStrings("\x1b\x1b", writer.buffered()); + } + + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .escape, + .mods = .{ .alt = true }, + }, .{ .modify_other_keys_state_2 = true }); + try testing.expectEqualStrings("\x1b[27;3;27~", writer.buffered()); + } +} + test "legacy: ctrl+shift+minus (underscore on US)" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); @@ -2021,6 +2129,63 @@ test "legacy: alt+e only unshifted" { try testing.expectEqualStrings("\x1Be", writer.buffered()); } +test "legacy: alt+unicode" { + const Case = struct { + text: []const u8, + codepoint: u21, + expected: []const u8, + }; + const cases = [_]Case{ + .{ .text = "é", .codepoint = 'é', .expected = "\x1B" ++ "é" }, + .{ .text = "ő", .codepoint = 'ő', .expected = "\x1B" ++ "ő" }, + .{ .text = "界", .codepoint = '界', .expected = "\x1B" ++ "界" }, + .{ .text = "😀", .codepoint = '😀', .expected = "\x1B" ++ "😀" }, + }; + + for (cases) |case| { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .unidentified, + .utf8 = case.text, + .unshifted_codepoint = case.codepoint, + .mods = .{ .alt = true }, + }, .{ + .alt_esc_prefix = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings(case.expected, writer.buffered()); + } +} + +test "legacy: alt+unicode only unshifted" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .unidentified, + .unshifted_codepoint = '界', + .mods = .{ .alt = true }, + }, .{ + .alt_esc_prefix = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1B" ++ "界", writer.buffered()); +} + +test "legacy: alt+unicode without unshifted" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .unidentified, + .utf8 = "😀", + .mods = .{ .alt = true }, + }, .{ + .alt_esc_prefix = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1B" ++ "😀", writer.buffered()); +} + test "legacy: alt+x macos" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; @@ -2064,8 +2229,9 @@ test "legacy: alt+ф" { .mods = .{ .alt = true }, }, .{ .alt_esc_prefix = true, + .macos_option_as_alt = .true, }); - try testing.expectEqualStrings("ф", writer.buffered()); + try testing.expectEqualStrings("\x1B" ++ "ф", writer.buffered()); } test "legacy: ctrl+c" { @@ -2157,6 +2323,30 @@ test "legacy: ctrl+shift+char with modify other state 2" { try testing.expectEqualStrings("\x1b[27;6;72~", writer.buffered()); } +test "legacy: ctrl+char with modify other state 2" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_p, + .mods = .{ .ctrl = true }, + .utf8 = "p", + }, .{ + .modify_other_keys_state_2 = true, + }); + try testing.expectEqualStrings("\x1b[27;5;112~", writer.buffered()); +} + +test "legacy: ctrl+char without modify other state 2" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_p, + .mods = .{ .ctrl = true }, + .utf8 = "p", + }, .{}); + try testing.expectEqualStrings("\x10", writer.buffered()); +} + test "legacy: ctrl+shift+char with modify other state 2 and consumed mods" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); @@ -2293,6 +2483,35 @@ test "legacy: keypad 1" { try testing.expectEqualStrings("1", writer.buffered()); } +test "legacy: keypad 1 with modify other state 2" { + var buf: [128]u8 = undefined; + + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{ .ctrl = true }, + .utf8 = "1", + }, .{ + .modify_other_keys_state_2 = true, + }); + try testing.expectEqualStrings("1", writer.buffered()); + } + + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{ .ctrl = true }, + .utf8 = "1", + }, .{ + .keypad_key_application = true, + .modify_other_keys_state_2 = true, + }); + try testing.expectEqualStrings("\x1bO5q", writer.buffered()); + } +} + test "legacy: keypad 1 with application keypad" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); @@ -2395,6 +2614,91 @@ test "legacy: f1" { } } +test "legacy: f13 through f25" { + const Case = struct { key: key.Key, code: u8 }; + const cases = [_]Case{ + .{ .key = .f13, .code = 25 }, + .{ .key = .f14, .code = 26 }, + .{ .key = .f15, .code = 28 }, + .{ .key = .f16, .code = 29 }, + .{ .key = .f17, .code = 31 }, + .{ .key = .f18, .code = 32 }, + .{ .key = .f19, .code = 33 }, + .{ .key = .f20, .code = 34 }, + .{ .key = .f21, .code = 42 }, + .{ .key = .f22, .code = 43 }, + .{ .key = .f23, .code = 44 }, + .{ .key = .f24, .code = 45 }, + .{ .key = .f25, .code = 46 }, + }; + + var buf: [128]u8 = undefined; + var expected_buf: [32]u8 = undefined; + for (cases) |case| { + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = case.key }, .{}); + const expected = try std.fmt.bufPrint( + &expected_buf, + "\x1b[{}~", + .{case.code}, + ); + try testing.expectEqualStrings(expected, writer.buffered()); + } + + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = case.key, + .mods = .{ .ctrl = true }, + }, .{ .modify_other_keys_state_2 = true }); + const expected = try std.fmt.bufPrint( + &expected_buf, + "\x1b[{};5~", + .{case.code}, + ); + try testing.expectEqualStrings(expected, writer.buffered()); + } + } +} + +test "legacy: help and context menu" { + const Case = struct { key: key.Key, code: u8 }; + const cases = [_]Case{ + .{ .key = .help, .code = 28 }, + .{ .key = .context_menu, .code = 29 }, + }; + + var buf: [128]u8 = undefined; + var expected_buf: [32]u8 = undefined; + for (cases) |case| { + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = case.key }, .{}); + const expected = try std.fmt.bufPrint( + &expected_buf, + "\x1b[{}~", + .{case.code}, + ); + try testing.expectEqualStrings(expected, writer.buffered()); + } + + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = case.key, + .mods = .{ .ctrl = true }, + }, .{ .modify_other_keys_state_2 = true }); + const expected = try std.fmt.bufPrint( + &expected_buf, + "\x1b[{};5~", + .{case.code}, + ); + try testing.expectEqualStrings(expected, writer.buffered()); + } + } +} + test "legacy: left_shift+tab" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); |
