aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/input/key_encode.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/input/key_encode.zig
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (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.zig428
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);