input: handle more ctrl+<key> sequences, namely ctrl+_

Previously, we encoded `ctrl+_` in the CSIu format[1]. This breaks most
notably emacs which expects the legacy ambiguous encoding.

This commit utilizes the generator from Kitty to generate our control
key mappings. We also switch from keycode mapping to key contents
mapping which appears to be the correct behavior also compared to other
terminals.

In the course of doing this, I also found one bug with our fixterms
implementation. Fixterms states: "The Shift key should not be considered
as a modifier for Unicode characters, because it is most likely used to
obtain the character in the first place (e.g. the shift key is often
required to obtain the ! symbol)." We were not applying that logic and
now do.

[1]: https://www.leonerd.org.uk/hacks/fixterms/
This commit is contained in:
Mitchell Hashimoto
2024-01-25 14:59:36 -08:00
parent 5fa34908e4
commit bf4211e060
2 changed files with 137 additions and 67 deletions

View File

@ -918,6 +918,20 @@ pub const Surface = struct {
=> .invalid, => .invalid,
}; };
// This is a hack for GLFW. We require our apprts to send both
// the UTF8 encoding AND the keypress at the same time. Its critical
// for things like ctrl sequences to work. However, GLFW doesn't
// provide this information all at once. So we just infer based on
// the key press. This isn't portable but GLFW is only for testing.
const utf8 = switch (key) {
inline else => |k| utf8: {
if (mods.shift) break :utf8 "";
const cp = k.codepoint() orelse break :utf8 "";
const byte = std.math.cast(u8, cp) orelse break :utf8 "";
break :utf8 &.{byte};
},
};
const key_event: input.KeyEvent = .{ const key_event: input.KeyEvent = .{
.action = action, .action = action,
.key = key, .key = key,
@ -925,7 +939,8 @@ pub const Surface = struct {
.mods = mods, .mods = mods,
.consumed_mods = .{}, .consumed_mods = .{},
.composing = false, .composing = false,
.utf8 = "", .utf8 = utf8,
.unshifted_codepoint = if (utf8.len > 0) @intCast(utf8[0]) else 0,
}; };
const effect = core_win.keyCallback(key_event) catch |err| { const effect = core_win.keyCallback(key_event) catch |err| {

View File

@ -262,7 +262,7 @@ fn legacy(
// If we match a control sequence, we output that directly. For // If we match a control sequence, we output that directly. For
// ctrlSeq we have to use all mods because we want it to only // ctrlSeq we have to use all mods because we want it to only
// match ctrl+<char>. // match ctrl+<char>.
if (ctrlSeq(self.event.key, all_mods)) |char| { if (ctrlSeq(self.event.utf8, all_mods)) |char| {
// C0 sequences support alt-as-esc prefixing. // C0 sequences support alt-as-esc prefixing.
if (binding_mods.alt) { if (binding_mods.alt) {
if (buf.len < 2) return error.OutOfMemory; if (buf.len < 2) return error.OutOfMemory;
@ -341,7 +341,19 @@ fn legacy(
// effective mods. The fixterms spec states the shifted chars // effective mods. The fixterms spec states the shifted chars
// should be sent uppercase but Kitty changes that behavior // should be sent uppercase but Kitty changes that behavior
// so we'll send all the mods. // so we'll send all the mods.
const csi_u_mods = CsiUMods.fromInput(self.event.mods); const csi_u_mods = mods: {
var mods = CsiUMods.fromInput(self.event.mods);
// If our unshifted codepoint is identical to the shifted
// then we consider shift. Otherwise, we do not because the
// shift key was used to obtain the character. This is specified
// by fixterms.
if (self.event.unshifted_codepoint != @as(u21, @intCast(utf8[0]))) {
mods.shift = false;
}
break :mods mods;
};
const result = try std.fmt.bufPrint( const result = try std.fmt.bufPrint(
buf, buf,
"\x1B[{};{}u", "\x1B[{};{}u",
@ -486,70 +498,92 @@ fn pcStyleFunctionKey(
/// This will return null if the key event should not be converted /// This will return null if the key event should not be converted
/// into a C0 byte. There are many cases for this and you should read /// into a C0 byte. There are many cases for this and you should read
/// the source code to understand them. /// the source code to understand them.
fn ctrlSeq(keyval: key.Key, mods: key.Mods) ?u8 { fn ctrlSeq(utf8: []const u8, mods: key.Mods) ?u8 {
// If ctrl is not pressed then we never do anything.
if (!mods.ctrl) return null;
// If we don't have exactly one byte in our utf8 sequence, then
// we don't do anything, since all our ctrl keys are based on ASCII.
if (utf8.len != 1) return null;
const char = utf8[0];
const unset_mods = unset_mods: {
var unset_mods = mods;
// Remove alt from our modifiers because it does not impact whether // Remove alt from our modifiers because it does not impact whether
// we are generating a ctrl sequence. // we are generating a ctrl sequence and we handle the ESC-prefix
const unalt_mods = unalt_mods: { // logic separately.
var unalt_mods = mods; unset_mods.alt = false;
unalt_mods.alt = false;
break :unalt_mods unalt_mods.binding(); // Remove shift if we have something outside of the US letter range
if (unset_mods.shift and (char < 'A' or char > 'Z')) shift: {
// Special case for fixterms awkward case as specified.
if (char == '@') break :shift;
unset_mods.shift = false;
}
break :unset_mods unset_mods.binding();
}; };
// If we have any other modifier key set, then we do not generate // After unsetting, we only continue if we have ONLY control set.
// a C0 sequence.
const ctrl_only = comptime (key.Mods{ .ctrl = true }).int(); const ctrl_only = comptime (key.Mods{ .ctrl = true }).int();
if (unalt_mods.int() != ctrl_only) return null; if (unset_mods.int() != ctrl_only) return null;
// The normal approach to get this value is to make the ascii byte // From Kitty's key encoding logic. I tried to discern the exact
// with 0x1F. However, not all apprt key translation will properly // behavior across different terminals but it's not clear, so I'm
// generate the correct value so we just hardcode this based on // just going to repeat what Kitty does.
// logical key. return switch (char) {
return switch (keyval) { ' ' => 0,
.space => 0, '/' => 31,
.slash => 0x1F, '0' => 48,
.zero => 0x30, '1' => 49,
.one => 0x31, '2' => 0,
.two => 0x00, '3' => 27,
.three => 0x1B, '4' => 28,
.four => 0x1C, '5' => 29,
.five => 0x1D, '6' => 30,
.six => 0x1E, '7' => 31,
.seven => 0x1F, '8' => 127,
.eight => 0x7F, '9' => 57,
.nine => 0x39, '?' => 127,
.backslash => 0x1C, '@' => 0,
.right_bracket => 0x1D, '\\' => 28,
.a => 0x01, ']' => 29,
.b => 0x02, '^' => 30,
.c => 0x03, '_' => 31,
.d => 0x04, 'a' => 1,
.e => 0x05, 'b' => 2,
.f => 0x06, 'c' => 3,
.g => 0x07, 'd' => 4,
.h => 0x08, 'e' => 5,
.j => 0x0A, 'f' => 6,
.k => 0x0B, 'g' => 7,
.l => 0x0C, 'h' => 8,
.n => 0x0E, 'j' => 10,
.o => 0x0F, 'k' => 11,
.p => 0x10, 'l' => 12,
.q => 0x11, 'n' => 14,
.r => 0x12, 'o' => 15,
.s => 0x13, 'p' => 16,
.t => 0x14, 'q' => 17,
.u => 0x15, 'r' => 18,
.v => 0x16, 's' => 19,
.w => 0x17, 't' => 20,
.x => 0x18, 'u' => 21,
.y => 0x19, 'v' => 22,
.z => 0x1A, 'w' => 23,
'x' => 24,
'y' => 25,
'z' => 26,
'~' => 30,
// These are purposely NOT handled here because of the fixterms // These are purposely NOT handled here because of the fixterms
// specification: https://www.leonerd.org.uk/hacks/fixterms/ // specification: https://www.leonerd.org.uk/hacks/fixterms/
// These are processed as CSI u. // These are processed as CSI u.
// .i => 0x09, // 'i' => 0x09,
// .m => 0x0D, // 'm' => 0x0D,
// .left_bracket => 0x1B, // '[' => 0x1B,
else => null, else => null,
}; };
@ -1476,12 +1510,27 @@ test "legacy: enter with utf8 (dead key state)" {
try testing.expectEqualStrings("A", actual); try testing.expectEqualStrings("A", actual);
} }
test "legacy: ctrl+shift+minus (underscore on US)" {
var buf: [128]u8 = undefined;
var enc: KeyEncoder = .{
.event = .{
.key = .minus,
.mods = .{ .ctrl = true, .shift = true },
.utf8 = "_",
},
};
const actual = try enc.legacy(&buf);
try testing.expectEqualStrings("\x1F", actual);
}
test "legacy: ctrl+alt+c" { test "legacy: ctrl+alt+c" {
var buf: [128]u8 = undefined; var buf: [128]u8 = undefined;
var enc: KeyEncoder = .{ var enc: KeyEncoder = .{
.event = .{ .event = .{
.key = .c, .key = .c,
.mods = .{ .ctrl = true, .alt = true }, .mods = .{ .ctrl = true, .alt = true },
.utf8 = "c",
}, },
}; };
@ -1580,6 +1629,7 @@ test "legacy: ctrl+c" {
.event = .{ .event = .{
.key = .c, .key = .c,
.mods = .{ .ctrl = true }, .mods = .{ .ctrl = true },
.utf8 = "c",
}, },
}; };
@ -1593,6 +1643,7 @@ test "legacy: ctrl+space" {
.event = .{ .event = .{
.key = .space, .key = .space,
.mods = .{ .ctrl = true }, .mods = .{ .ctrl = true },
.utf8 = " ",
}, },
}; };
@ -1658,15 +1709,14 @@ test "legacy: fixterm awkward letters" {
try testing.expectEqualStrings("\x1b[91;5u", actual); try testing.expectEqualStrings("\x1b[91;5u", actual);
} }
{ {
// This doesn't exactly match the fixterm spec but matches the
// behavior of Kitty.
var enc: KeyEncoder = .{ .event = .{ var enc: KeyEncoder = .{ .event = .{
.key = .two, .key = .two,
.mods = .{ .ctrl = true, .shift = true }, .mods = .{ .ctrl = true, .shift = true },
.utf8 = "@", .utf8 = "@",
.unshifted_codepoint = '2',
} }; } };
const actual = try enc.legacy(&buf); const actual = try enc.legacy(&buf);
try testing.expectEqualStrings("\x1b[64;6u", actual); try testing.expectEqualStrings("\x1b[64;5u", actual);
} }
} }
@ -1841,20 +1891,25 @@ test "legacy: right_shift+tab" {
} }
test "ctrlseq: normal ctrl c" { test "ctrlseq: normal ctrl c" {
const seq = ctrlSeq(.c, .{ .ctrl = true }); const seq = ctrlSeq("c", .{ .ctrl = true });
try testing.expectEqual(@as(u8, 0x03), seq.?); try testing.expectEqual(@as(u8, 0x03), seq.?);
} }
test "ctrlseq: alt should be allowed" { test "ctrlseq: alt should be allowed" {
const seq = ctrlSeq(.c, .{ .alt = true, .ctrl = true }); const seq = ctrlSeq("c", .{ .alt = true, .ctrl = true });
try testing.expectEqual(@as(u8, 0x03), seq.?); try testing.expectEqual(@as(u8, 0x03), seq.?);
} }
test "ctrlseq: no ctrl does nothing" { test "ctrlseq: no ctrl does nothing" {
try testing.expect(ctrlSeq(.c, .{}) == null); try testing.expect(ctrlSeq("c", .{}) == null);
} }
test "ctrlseq: shift does not generate ctrl seq" { test "ctrlseq: shift does not generate ctrl seq" {
try testing.expect(ctrlSeq(.c, .{ .shift = true }) == null); try testing.expect(ctrlSeq("C", .{ .shift = true }) == null);
try testing.expect(ctrlSeq(.c, .{ .shift = true, .ctrl = true }) == null); try testing.expect(ctrlSeq("C", .{ .shift = true, .ctrl = true }) == null);
}
test "ctrlseq: shifted non-character" {
const seq = ctrlSeq("_", .{ .ctrl = true, .shift = true });
try testing.expectEqual(@as(u8, 0x1F), seq.?);
} }