input: skip text fallback for kitty key releases (#13861)

Key events without a kitty entry fall back to writing their UTF-8 text
directly. On GTK, keys whose unshifted keysym is a dead key or level 5
latch have no unshifted codepoint and take this path. With event type
reporting enabled, releases therefore emitted the same text as presses
and duplicated characters in applications such as Neovim.

Skip the raw text fallback for release events while retaining it for
presses and repeats. Keep the guard in the shared encoder so release
events for identified keys still retain the UTF-8 data used to derive
alternate keys.

Cover releases with and without report-all mode, and verify that repeat
events continue to emit fallback text.

  - https://github.com/ghostty-org/ghostty/discussions/12192
  - https://github.com/ghostty-org/ghostty/discussions/12084
  - https://github.com/ghostty-org/ghostty/discussions/12433
  - https://github.com/ghostty-org/ghostty/discussions/13816

## Testing

  - `zig build test-lib-vt -Dtarget=x86_64-linux-gnu`
  - `zig build -Demit-lib-vt -Dtarget=x86_64-linux-gnu`
  - `zig build`
  - Verified the regression test fails without the release guard
- Manually tested the GTK backend under Wayland/Sway and X11/XWayland,
with the GTK simple input context and ibus 1.5.34:
    - Ergo-L `!` and `'`
    - Spanish `[`, `{`, `]`, and `}`
- Presses, repeats, and both modifier-release orders in `nvim --clean`
    - Dead-key composition and cancellation
    - Unicode hexadecimal input
- Full kitty keyboard mode with `kitty +kitten show_key -m kitty`,
including composed text

## AI disclosure

OpenAI Codex assisted with investigating the reports, reviewing the GTK
and kitty input paths, extending the regression tests, running
validation, and drafting this description. I reviewed the final code,
edited this description, manually performed the tests listed above, and
understand how the change interacts with the input encoder.
This commit is contained in:
Jeffrey C. Ollie
2026-08-16 16:32:35 -05:00
committed by GitHub

View File

@@ -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.
@@ -1492,6 +1494,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);