From 8b85dcb9ea566f76b8a7f753746c70ae89e3d59b Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 28 Jun 2026 07:27:34 -0700 Subject: [PATCH] handle upper case in input buffers --- src/tui/input_buffer.zig | 105 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 8 deletions(-) diff --git a/src/tui/input_buffer.zig b/src/tui/input_buffer.zig index 8e27cb0..1f768ef 100644 --- a/src/tui/input_buffer.zig +++ b/src/tui/input_buffer.zig @@ -80,12 +80,9 @@ pub fn handleKey(buf: []u8, len: *usize, key: vaxis.Key) Result { len.* = 0; return .edited; } - // Accept printable ASCII (letters, digits, common punctuation). - if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) { - buf[len.*] = @intCast(key.codepoint); - len.* += 1; - return .edited; - } + // Printable input. Prefer the terminal-resolved text so Shift / + // Caps Lock produce the right case (see `appendPrintable`). + if (appendPrintable(buf, len, key)) return .edited; return .ignored; } @@ -155,12 +152,45 @@ pub fn handleKeyMulti(buf: []u8, len: *usize, key: vaxis.Key) MultiResult { len.* = 0; return .edited; } + if (appendPrintable(buf, len, key)) return .edited; + return .ignored; +} + +/// Append the character a key produced to `buf`, returning true on +/// success. Callers handle the special keys (Esc, Enter, Backspace, +/// Ctrl+*) before this runs, so anything reaching here is ordinary +/// text input. +/// +/// We prefer `key.text` - the terminal's resolved text for the +/// event - over the raw codepoint. Under the Kitty keyboard protocol +/// (which vaxis turns on), a Shift+a press reports `codepoint = 'a'` +/// (the base-layout key) with `text = "A"`; keying off the codepoint +/// alone silently downcases everything and turns shifted symbols +/// (`!@#`) back into their digits. Only single printable-ASCII text +/// bytes are taken: these buffers are ASCII (ticker symbols, ack-note +/// reasoning), so a stray multibyte grapheme or control sequence +/// shouldn't land in them. When no text is reported (legacy terminal, +/// no Kitty protocol) we fall back to the codepoint, which already +/// carries the shifted value in that mode. +fn appendPrintable(buf: []u8, len: *usize, key: vaxis.Key) bool { + if (key.text) |text| { + if (text.len == 1 and std.ascii.isPrint(text[0]) and len.* < buf.len) { + buf[len.*] = text[0]; + len.* += 1; + return true; + } + // Text was reported but isn't a single printable ASCII byte + // (multibyte grapheme, control char, or the buffer is full). + // Do NOT fall through to the codepoint path: that would + // re-append a downcased/duplicate byte. + return false; + } if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) { buf[len.*] = @intCast(key.codepoint); len.* += 1; - return .edited; + return true; } - return .ignored; + return false; } // ── Tests ───────────────────────────────────────────────────── @@ -194,6 +224,49 @@ test "handleKey: printable ASCII appends and increments len" { try testing.expectEqual(@as(u8, 'x'), buf[0]); } +test "handleKey: Shift+letter appends the uppercase text, not the base codepoint" { + // Kitty keyboard protocol reports the base-layout key in + // `codepoint` and the resolved character in `text`. Shift+a must + // land 'A', not 'a'. + var buf: [16]u8 = undefined; + var len: usize = 0; + const result = handleKey(&buf, &len, .{ .codepoint = 'a', .text = "A", .mods = .{ .shift = true } }); + try testing.expectEqual(Result.edited, result); + try testing.expectEqual(@as(usize, 1), len); + try testing.expectEqual(@as(u8, 'A'), buf[0]); +} + +test "handleKey: shifted symbol appends the symbol, not the digit" { + // Shift+1 => '!' : codepoint stays '1', text is "!". + var buf: [16]u8 = undefined; + var len: usize = 0; + const result = handleKey(&buf, &len, .{ .codepoint = '1', .text = "!", .mods = .{ .shift = true } }); + try testing.expectEqual(Result.edited, result); + try testing.expectEqual(@as(u8, '!'), buf[0]); +} + +test "handleKey: legacy terminal (no text) falls back to the codepoint" { + // Without the Kitty protocol, vaxis reports the shifted value + // directly in `codepoint` and leaves `text` null. + var buf: [16]u8 = undefined; + var len: usize = 0; + const result = handleKey(&buf, &len, .{ .codepoint = 'A' }); + try testing.expectEqual(Result.edited, result); + try testing.expectEqual(@as(u8, 'A'), buf[0]); +} + +test "handleKey: multibyte/control text is not appended" { + // A non-ASCII grapheme or control sequence in `text` must not + // corrupt the ASCII buffer, and must not fall through to the + // codepoint path. + var buf: [16]u8 = undefined; + var len: usize = 0; + try testing.expectEqual(Result.ignored, handleKey(&buf, &len, .{ .codepoint = 0x2014, .text = "\u{2014}" })); + try testing.expectEqual(@as(usize, 0), len); + try testing.expectEqual(Result.ignored, handleKey(&buf, &len, .{ .codepoint = vaxis.Key.tab, .text = "\t" })); + try testing.expectEqual(@as(usize, 0), len); +} + test "handleKey: backspace decrements len" { var buf: [16]u8 = undefined; var len: usize = 3; @@ -296,6 +369,22 @@ test "handleKeyMulti: printable ASCII appends" { try testing.expectEqual(@as(u8, 'x'), buf[0]); } +test "handleKeyMulti: Shift+letter appends uppercase text (the ack-note bug)" { + var buf: [64]u8 = undefined; + var len: usize = 0; + const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'a', .text = "A", .mods = .{ .shift = true } }); + try testing.expectEqual(MultiResult.edited, result); + try testing.expectEqual(@as(u8, 'A'), buf[0]); +} + +test "handleKeyMulti: legacy terminal (no text) falls back to the codepoint" { + var buf: [64]u8 = undefined; + var len: usize = 0; + const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'Z' }); + try testing.expectEqual(MultiResult.edited, result); + try testing.expectEqual(@as(u8, 'Z'), buf[0]); +} + test "handleKeyMulti: backspace decrements len" { var buf: [64]u8 = undefined; var len: usize = 3;