diff options
Diffstat (limited to 'vendor/libghostty-vt/src/input/key_encode.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/input/key_encode.zig | 2424 |
1 files changed, 2424 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig new file mode 100644 index 00000000..52bb556e --- /dev/null +++ b/vendor/libghostty-vt/src/input/key_encode.zig @@ -0,0 +1,2424 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const testing = std.testing; +const KittyFlags = @import("../terminal/kitty/key.zig").Flags; +const OptionAsAlt = @import("config.zig").OptionAsAlt; +const Terminal = @import("../terminal/Terminal.zig"); +const function_keys = @import("function_keys.zig"); +const key = @import("key.zig"); +const KittyEntry = @import("kitty.zig").Entry; +const kitty_entries = @import("kitty.zig").entries; + +/// Options that affect key encoding behavior. This is a mix of behavior +/// from terminal state as well as application configuration. +pub const Options = struct { + /// Terminal DEC mode 1 + cursor_key_application: bool = false, + + /// Terminal DEC mode 66 + keypad_key_application: bool = false, + + /// Terminal DEC mode 1035 + ignore_keypad_with_numlock: bool = false, + + /// Terminal DEC mode 1036 + alt_esc_prefix: bool = false, + + /// xterm "modifyOtherKeys mode 2". Details here: + /// https://invisible-island.net/xterm/modified-keys.html + modify_other_keys_state_2: bool = false, + + /// Kitty keyboard protocol flags. + kitty_flags: KittyFlags = .disabled, + + /// Determines whether the "option" key on macOS is treated + /// as "alt" or not. See the Ghostty `macos_option-as-alt` config + /// docs for a more detailed description of why this is needed. + macos_option_as_alt: OptionAsAlt = .false, + + pub const default: Options = .{ + .cursor_key_application = false, + .keypad_key_application = false, + .ignore_keypad_with_numlock = false, + .alt_esc_prefix = false, + .modify_other_keys_state_2 = false, + .kitty_flags = .disabled, + .macos_option_as_alt = .false, + }; + + /// Initialize our options from the terminal state. + /// + /// Note that `macos_option_as_alt` cannot be determined from + /// terminal state so it must be set manually after this call. + pub fn fromTerminal(t: *const Terminal) Options { + return .{ + .alt_esc_prefix = t.modes.get(.alt_esc_prefix), + .cursor_key_application = t.modes.get(.cursor_keys), + .keypad_key_application = t.modes.get(.keypad_keys), + .ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock), + .modify_other_keys_state_2 = t.flags.modify_other_keys_2, + .kitty_flags = t.screens.active.kitty_keyboard.current(), + + // These can't be known from the terminal state. + .macos_option_as_alt = .false, + }; + } +}; + +/// Encode the key event to the writer in the proper format given +/// the options. For example, this will properly encode a key press +/// such as "ctrl+A" to Kitty format if Kitty encoding is enabled. +/// +/// Not all key events will result in output. It is up to the caller +/// to use a writer that can track whether any output was written if +/// they care about that. +pub fn encode( + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { + //std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); + return if (opts.kitty_flags.int() != 0) try kitty( + writer, + event, + opts, + ) else try legacy( + writer, + event, + opts, + ); +} + +/// Perform Kitty keyboard protocol encoding of the key event. +fn kitty( + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { + // This should never happen but we'll check anyway. + if (opts.kitty_flags.int() == 0) return try legacy( + writer, + event, + opts, + ); + + // We only processed "press" events unless report events is active + if (event.action == .release) { + if (!opts.kitty_flags.report_events) return; + + // Enter, backspace, and tab do not report release events unless "report + // all" is set + if (!opts.kitty_flags.report_all) { + switch (event.key) { + .enter, .backspace, .tab => return, + else => {}, + } + } + } + + const all_mods = event.mods; + const effective_mods = event.effectiveMods(); + const binding_mods = effective_mods.binding(); + + // Find the entry for this key in the kitty table. + const entry_: ?KittyEntry = entry: { + // Functional or predefined keys + for (kitty_entries) |entry| { + if (entry.key == event.key) break :entry entry; + } + + // Otherwise, we use our unicode codepoint from UTF8. We + // always use the unshifted value. + if (event.unshifted_codepoint > 0) { + break :entry .{ + .key = event.key, + .code = event.unshifted_codepoint, + .final = 'u', + .modifier = false, + }; + } + + break :entry null; + }; + + preprocessing: { + // When composing, the only keys sent are plain modifiers. + if (event.composing) { + if (entry_) |entry| { + if (entry.modifier) break :preprocessing; + } + + return; + } + + // IME confirmation still sends an enter key so if we have enter + // and UTF8 text we just send it directly since we assume that is + // what's happening. See legacy()'s similar logic for more details + // on how to verify this. + if (event.utf8.len > 0) utf8: { + switch (event.key) { + else => {}, + inline .enter, .backspace => |tag| { + // See legacy for why we handle this way. + if (isControlUtf8(event.utf8)) break :utf8; + if (comptime tag == .backspace) return; + return try writer.writeAll(event.utf8); + }, + } + } + + // If we're reporting all then we always send CSI sequences. + if (!opts.kitty_flags.report_all) { + // Quote: + // The only exceptions are the Enter, Tab and Backspace keys which + // still generate the same bytes as in legacy mode this is to allow the + // user to type and execute commands in the shell such as reset after a + // program that sets this mode crashes without clearing it. + // + // Quote ("report all" mode): + // Note that all keys are reported as escape codes, including Enter, + // Tab, Backspace etc. + if (binding_mods.empty()) { + switch (event.key) { + .enter => return try writer.writeByte('\r'), + .tab => return try writer.writeByte('\t'), + .backspace => return try writer.writeByte(0x7F), + else => {}, + } + } + + // Send plain-text non-modified text directly to the terminal. + // We don't send release events because those are specially encoded. + if (event.utf8.len > 0 and + binding_mods.empty() and + event.action != .release) + plain_text: { + // We only do this for printable characters. We should + // inspect the real unicode codepoint properties here but + // the real world issue is usually control characters. + const view = std.unicode.Utf8View.init(event.utf8) catch { + // Invalid UTF-8 so let's fallback to encoding the + // key press as if it didn't produce UTF-8 text. I'm + // not sure what should happen here according to the spec, + // since it doesn't specify this behavior. Presumably + // this is a caller bug. + break :plain_text; + }; + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + if (isControl(cp)) break :plain_text; + } + + return try writer.writeAll(event.utf8); + } + } + } + + 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; + }; + + // If this is just a modifier we require "report all" to send the sequence. + if (entry.modifier and !opts.kitty_flags.report_all) return; + + const seq: KittySequence = seq: { + var seq: KittySequence = .{ + .key = entry.code, + .final = entry.final, + .mods = .fromInput( + event.action, + event.key, + all_mods, + ), + }; + + if (opts.kitty_flags.report_events) { + seq.event = switch (event.action) { + .press => .press, + .release => .release, + .repeat => .repeat, + }; + } + + if (opts.kitty_flags.report_alternates) alternates: { + // Break early if this is a control key + if (isControl(seq.key)) break :alternates; + + const view = std.unicode.Utf8View.init(event.utf8) catch { + // Assume invalid UTF-8 means no UTF-8. + break :alternates; + }; + var it = view.iterator(); + + // If we have a codepoint in our UTF-8 sequence, then we can + // report the shifted version. + if (it.nextCodepoint()) |cp1| { + // Set the first alternate (shifted version) + if (cp1 != seq.key and seq.mods.shift) seq.alternates[0] = cp1; + + // We want to know if there are additional codepoints because + // our logic below depends on the utf8 being a single codepoint. + const has_cp2 = it.nextCodepoint() != null; + + // Set the base layout key. We only report this if this codepoint + // differs from our pressed key. + if (event.key.codepoint()) |base| { + if (base != seq.key and + (cp1 != base and !has_cp2)) + { + seq.alternates[1] = base; + } + } + } else { + // No UTF-8 so we can't report a shifted key but we can still + // report a base layout key. + if (event.key.codepoint()) |base| { + if (base != seq.key) seq.alternates[1] = base; + } + } + } + + if (opts.kitty_flags.report_associated and + seq.event != .release) + associated: { + // Determine if the Alt modifier should be treated as an actual + // modifier (in which case it prevents associated text) or as + // the macOS Option key, which does not prevent associated text. + const alt_prevents_text = if (comptime builtin.os.tag == .macos) + switch (opts.macos_option_as_alt) { + .left => all_mods.sides.alt == .left, + .right => all_mods.sides.alt == .right, + .true => true, + .false => false, + } + else + true; + + if (seq.mods.preventsText(alt_prevents_text)) break :associated; + + seq.text = event.utf8; + } + + break :seq seq; + }; + + return try seq.encode(writer); +} + +/// Perform legacy encoding of the key event. "Legacy" in this case +/// is referring to the behavior of traditional terminals, plus +/// xterm's `modifyOtherKeys`, plus Paul Evans's "fixterms" spec. +/// These together combine the legacy protocol because they're all +/// meant to be extensions that do not change any existing behavior +/// and therefore safe to combine. +fn legacy( + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { + const all_mods = event.mods; + const effective_mods = event.effectiveMods(); + const binding_mods = effective_mods.binding(); + + // Legacy encoding only does press/repeat + if (event.action != .press and event.action != .repeat) return; + + // If we're in a dead key state then we never emit a sequence. + if (event.composing) return; + + // If we match a PC style function key then that is our result. + if (pcStyleFunctionKey( + event.key, + all_mods, + opts.cursor_key_application, + opts.keypad_key_application, + opts.ignore_keypad_with_numlock, + opts.modify_other_keys_state_2, + )) |sequence| pc_style: { + // If we have UTF-8 text, then we never emit PC style function + // keys. Many function keys (escape, enter, backspace) have + // a specific meaning when dead keys are active and so we don't + // want to send that to the terminal. Examples: + // + // - Japanese: escape clears the dead key state + // - Korean: escape commits the dead key state + // - Korean: backspace should delete a single preedit char + // + if (event.utf8.len > 0) utf8: { + switch (event.key) { + else => {}, + inline .backspace, .enter, .escape => |tag| { + // We want to ignore control characters. This is because + // some apprts (macOS) will send control characters as + // UTF-8 encodings and we handle that manually. + if (isControlUtf8(event.utf8)) break :utf8; + + // Backspace encodes nothing because we modified IME. + // Enter/escape don't encode the PC-style encoding + // because we want to encode committed text. + if (comptime tag == .backspace) return; + break :pc_style; + }, + } + } + + 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. + 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. + break :modify_other; + }; + var it = view.iterator(); + const codepoint = it.nextCodepoint() orelse break :modify_other; + + // We only do this if we have a single codepoint. There shouldn't + // ever be a multi-codepoint sequence that triggers this. + if (it.nextCodepoint() != null) break :modify_other; + + // The mods we encode for this are just the binding mods (shift, ctrl, + // super, alt unless it is actually option). + const mods = mods: { + var mods_binding = event.mods.binding(); + if (comptime builtin.target.os.tag.isDarwin()) alt: { + switch (opts.macos_option_as_alt) { + .false => {}, + .true => break :alt, + .left => if (event.mods.sides.alt == .left) break :alt, + .right => if (event.mods.sides.alt == .right) break :alt, + } + mods_binding.alt = false; + } + break :mods mods_binding; + }; + + // This copies xterm's `ModifyOtherKeys` function that returns + // whether modify other keys should be encoded for the given + // input. + const should_modify = should_modify: { + // xterm IsControlInput + if (codepoint >= 0x40 and codepoint <= 0x7F) + break :should_modify true; + + // If we have anything other than shift pressed, encode. + var mods_no_shift = mods; + mods_no_shift.shift = false; + if (!mods_no_shift.empty()) break :should_modify true; + + // We only have shift pressed. We only allow space. + if (codepoint == ' ') break :should_modify true; + + // This logic isn't complete but I don't fully understand + // the rest so I'm going to wait until we can have a + // reasonable test scenario. + break :should_modify false; + }; + + if (should_modify) { + for (function_keys.modifiers, 2..) |modset, code| { + if (!mods.equal(modset)) continue; + return try writer.print( + "\x1B[27;{};{}~", + .{ code, codepoint }, + ); + } + } + } + + // 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. + if (event.mods.ctrl) csiu: { + // Important: we want to use the original mods here, not the + // effective mods. The fixterms spec states the shifted chars + // should be sent uppercase but Kitty changes that behavior + // so we'll send all the mods. + const csi_u_mods, const char = mods: { + var mods = CsiUMods.fromInput(event.mods); + + // Get our codepoint. If we have more than one codepoint this + // can't be valid CSIu. + const view = std.unicode.Utf8View.init(event.utf8) catch break :csiu; + var it = view.iterator(); + var char = it.nextCodepoint() orelse break :csiu; + if (it.nextCodepoint() != null) break :csiu; + + // If our character is A to Z and we have shift set, then + // we lowercase it. This is a Kitty-specific behavior that + // we choose to follow and diverge from the fixterms spec. + // This makes it easier for programs to detect shifted letters + // for keybindings and is not just theoretical but used by + // real programs. + if (char >= 'A' and char <= 'Z' and mods.shift) { + // We want to rely on apprt to send us the correct + // unshifted codepoint... + char = @intCast(std.ascii.toLower(@intCast(char))); + } + + // 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 (event.unshifted_codepoint != char) { + mods.shift = false; + } + + break :mods .{ mods, char }; + }; + return try writer.print( + "\x1B[{};{}u", + .{ char, csi_u_mods.seqInt() }, + ); + } + + // 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( + event, + binding_mods, + all_mods, + opts, + )) |byte| { + return try writer.print("\x1B{c}", .{byte}); + } + + // 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 + // don't in native text inputs (i.e. TextEdit) and they also don't + // in other native terminals (Terminal.app officially but also + // iTerm2). + // + // For Linux, we continue to encode text because it is typical. + // For example on Gnome Console Super+b will encode a "b" character + // with legacy encoding. + if ((comptime builtin.os.tag == .macos) and all_mods.super) { + return; + } + + return try writer.writeAll(utf8); +} + +fn legacyAltPrefix( + event: key.KeyEvent, + binding_mods: key.Mods, + mods: key.Mods, + opts: Options, +) !?u8 { + // This only takes effect with alt pressed + if (!binding_mods.alt or !opts.alt_esc_prefix) return null; + + // 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, + .true => {}, + } + } + + // Otherwise, we require utf8 to already have the byte represented. + const utf8 = event.utf8; + if (utf8.len == 1) { + if (std.math.cast(u8, utf8[0])) |byte| { + return byte; + } + } + + // 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; + } + } + + // Else, we can't figure out the byte to alt-prefix so we + // exit this handling. + return null; +} + +/// A helper to memcpy a src value to a buffer and return the result. +fn copyToBuf(buf: []u8, src: []const u8) ![]const u8 { + if (src.len > buf.len) return error.OutOfMemory; + const result = buf[0..src.len]; + @memcpy(result, src); + return result; +} + +/// Determines whether the key should be encoded in the xterm +/// "PC-style Function Key" syntax (roughly). This is a hardcoded +/// table of keys and modifiers that result in a specific sequence. +fn pcStyleFunctionKey( + keyval: key.Key, + mods: key.Mods, + cursor_key_application: bool, + keypad_key_application_req: bool, + ignore_keypad_with_numlock: bool, + modify_other_keys: bool, // True if state 2 +) ?[]const u8 { + // We only want binding-sensitive mods because lock keys + // and directional modifiers (left/right) don't matter for + // pc-style function keys. + const mods_int = mods.binding().int(); + + // Keypad application keymode isn't super straightforward. + // On xterm, in VT220 mode, numlock alone is enough to trigger + // application mode. But in more modern modes, numlock is + // ignored by default via mode 1035 (default true). If mode + // 1035 is on, we always are in numerical keypad mode. If + // mode 1035 is off, we are in application mode if the + // proper numlock state is pressed. The numlock state is implicitly + // determined based on the keycode sent (i.e. 1 with numlock + // on will be kp_end). + const keypad_key_application = keypad: { + // If we're ignoring keypad then this is always false. + // In other words, we're always in numerical keypad mode. + if (ignore_keypad_with_numlock) break :keypad false; + + // If we're not ignoring then we enable the desired state. + break :keypad keypad_key_application_req; + }; + + for (function_keys.keys.get(keyval)) |entry| { + switch (entry.cursor) { + .any => {}, + .normal => if (cursor_key_application) continue, + .application => if (!cursor_key_application) continue, + } + + switch (entry.keypad) { + .any => {}, + .normal => if (keypad_key_application) continue, + .application => if (!keypad_key_application) continue, + } + + switch (entry.modify_other_keys) { + .any => {}, + .set => if (modify_other_keys) continue, + .set_other => if (!modify_other_keys) continue, + } + + const entry_mods_int = entry.mods.int(); + if (entry_mods_int == 0) { + if (mods_int != 0 and !entry.mods_empty_is_any) continue; + // mods are either empty, or empty means any so we allow it. + } else if (entry_mods_int != mods_int) { + // any set mods require an exact match + continue; + } + + return entry.sequence; + } + + return null; +} + +/// Returns the C0 byte for the key event if it should be used. +/// This converts a key event into the expected terminal behavior +/// such as Ctrl+C turning into 0x03, amongst many other translations. +/// +/// 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 +/// the source code to understand them. +fn ctrlSeq( + logical_key: key.Key, + utf8: []const u8, + unshifted_codepoint: u21, + mods: key.Mods, +) ?u8 { + const ctrl_only = comptime (key.Mods{ .ctrl = true }).int(); + + // If ctrl is not pressed then we never do anything. + if (!mods.ctrl) return null; + + const char, const unset_mods = unset_mods: { + // We need to only get binding modifiers so we strip lock + // keys, sides, etc. + var unset_mods = mods.binding(); + + // Remove alt from our modifiers because it does not impact whether + // we are generating a ctrl sequence and we handle the ESC-prefix + // logic separately. + unset_mods.alt = false; + + var char: u8 = char: { + // If we have exactly one UTF8 byte, we assume that is the + // character we want to convert to a C0 byte. + if (utf8.len == 1) break :char utf8[0]; + + // If we have a logical key that maps to a single byte + // printable character, we use that. History to explain this: + // this was added to support cyrillic keyboard layouts such + // as Russian and Mongolian. These layouts have a `c` key that + // maps to U+0441 (cyrillic small letter "c") but every + // terminal I've tested encodes this as ctrl+c. + if (logical_key.codepoint()) |cp| { + if (std.math.cast(u8, cp)) |byte| { + // For this specific case, we only map to the key if + // we have exactly ctrl pressed. This is because shift + // would modify the key and we don't know how to do that + // properly here (don't have the layout). And we want + // to encode shift as CSIu. + if (unset_mods.int() != ctrl_only) return null; + break :char byte; + } + } + + // Otherwise we don't have a character to convert that + // we can reliably map to a C0 byte. + return null; + }; + + // Remove shift if we have something outside of the US letter + // range. This is so that characters such as `ctrl+shift+-` + // generate the correct ctrl-seq (used by emacs). + 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; + } + + // If the character is uppercase, we convert it to lowercase. We + // rely on the unshifted codepoint to do this. This handles + // the scenario where we have caps lock pressed. Note that + // shifted characters are handled above, if we are just pressing + // shift then the ctrl-only check will fail later and we won't + // ctrl-seq encode. + if (char >= 'A' and char <= 'Z' and unshifted_codepoint > 0) { + if (std.math.cast(u8, unshifted_codepoint)) |byte| { + char = byte; + } + } + + // An additional note on caps lock and shift interaction. + // If we have caps lock set and an ASCII letter is pressed, + // we lowercase it (above). If we have only control pressed, + // we process it as a ctrl seq. For example ctrl+M with caps + // lock but no shift will encode as 0x0D. + // + // But, if you press ctrl+shift+m, this will not encode as a + // ctrl-seq and falls through to CSIu encoding. This lets programs + // detect the difference between ctrl+M and ctrl+shift+M. This + // diverges from the fixterms "spec" and most terminals. This + // only matches Kitty in behavior. But I believe this is a + // justified divergence because it's a useful distinction. + + break :unset_mods .{ char, unset_mods }; + }; + + // After unsetting, we only continue if we have ONLY control set. + if (unset_mods.int() != ctrl_only) return null; + + // From Kitty's key encoding logic. I tried to discern the exact + // behavior across different terminals but it's not clear, so I'm + // just going to repeat what Kitty does. + return switch (char) { + ' ' => 0, + '/' => 31, + '0' => 48, + '1' => 49, + '2' => 0, + '3' => 27, + '4' => 28, + '5' => 29, + '6' => 30, + '7' => 31, + '8' => 127, + '9' => 57, + '?' => 127, + '@' => 0, + '\\' => 28, + ']' => 29, + '^' => 30, + '_' => 31, + 'a' => 1, + 'b' => 2, + 'c' => 3, + 'd' => 4, + 'e' => 5, + 'f' => 6, + 'g' => 7, + 'h' => 8, + 'j' => 10, + 'k' => 11, + 'l' => 12, + 'n' => 14, + 'o' => 15, + 'p' => 16, + 'q' => 17, + 'r' => 18, + 's' => 19, + 't' => 20, + 'u' => 21, + 'v' => 22, + 'w' => 23, + 'x' => 24, + 'y' => 25, + 'z' => 26, + '~' => 30, + + // These are purposely NOT handled here because of the fixterms + // specification: https://www.leonerd.org.uk/hacks/fixterms/ + // These are processed as CSI u. + // 'i' => 0x09, + // 'm' => 0x0D, + // '[' => 0x1B, + + else => null, + }; +} + +/// Returns true if this is an ASCII control character, matches libc implementation. +fn isControl(cp: u21) bool { + return cp < 0x20 or cp == 0x7F; +} + +/// Returns true if this string is comprised of a single +/// control character. This returns false for multi-byte strings. +fn isControlUtf8(str: []const u8) bool { + return str.len == 1 and isControl(@intCast(str[0])); +} + +/// This is the bitmask for fixterm CSI u modifiers. +const CsiUMods = packed struct(u3) { + shift: bool = false, + alt: bool = false, + ctrl: bool = false, + + /// Convert an input mods value into the CSI u mods value. + pub fn fromInput(mods: key.Mods) CsiUMods { + return .{ + .shift = mods.shift, + .alt = mods.alt, + .ctrl = mods.ctrl, + }; + } + + /// Returns the raw int value of this packed struct. + pub fn int(self: CsiUMods) u3 { + return @bitCast(self); + } + + /// Returns the integer value sent as part of the CSI u sequence. + /// This adds 1 to the bitmask value as described in the spec. + pub fn seqInt(self: CsiUMods) u4 { + const raw: u4 = @intCast(self.int()); + return raw + 1; + } + + test "modifier sequence values" { + // This is all sort of trivially seen by looking at the code but + // we want to make sure we never regress this. + var mods: CsiUMods = .{}; + try testing.expectEqual(@as(u4, 1), mods.seqInt()); + + mods = .{ .shift = true }; + try testing.expectEqual(@as(u4, 2), mods.seqInt()); + + mods = .{ .alt = true }; + try testing.expectEqual(@as(u4, 3), mods.seqInt()); + + mods = .{ .ctrl = true }; + try testing.expectEqual(@as(u4, 5), mods.seqInt()); + + mods = .{ .alt = true, .shift = true }; + try testing.expectEqual(@as(u4, 4), mods.seqInt()); + + mods = .{ .ctrl = true, .shift = true }; + try testing.expectEqual(@as(u4, 6), mods.seqInt()); + + mods = .{ .alt = true, .ctrl = true }; + try testing.expectEqual(@as(u4, 7), mods.seqInt()); + + mods = .{ .alt = true, .ctrl = true, .shift = true }; + try testing.expectEqual(@as(u4, 8), mods.seqInt()); + } +}; + +/// This is the bitfields for Kitty modifiers. +const KittyMods = packed struct(u8) { + shift: bool = false, + alt: bool = false, + ctrl: bool = false, + super: bool = false, + hyper: bool = false, + meta: bool = false, + caps_lock: bool = false, + num_lock: bool = false, + + /// Convert an input mods value into the CSI u mods value. + pub fn fromInput( + action: key.Action, + k: key.Key, + mods: key.Mods, + ) KittyMods { + _ = action; + _ = k; + return .{ + .shift = mods.shift, + .alt = mods.alt, + .ctrl = mods.ctrl, + .super = mods.super, + .caps_lock = mods.caps_lock, + .num_lock = mods.num_lock, + }; + } + + /// Returns true if the modifiers prevent printable text. + /// + /// The alt_prevents_text parameter determines whether or not the Alt + /// modifier prevents printable text. On Linux, this is always true. On + /// macOS, this is only true if macos-option-as-alt is set. + pub fn preventsText(self: KittyMods, alt_prevents_text: bool) bool { + return (self.alt and alt_prevents_text) or + self.ctrl or + self.super or + self.hyper or + self.meta; + } + + /// Returns the raw int value of this packed struct. + pub fn int(self: KittyMods) u8 { + return @bitCast(self); + } + + /// Returns the integer value sent as part of the Kitty sequence. + /// This adds 1 to the bitmask value as described in the spec. + pub fn seqInt(self: KittyMods) u9 { + const raw: u9 = @intCast(self.int()); + return raw + 1; + } + + test "modifier sequence values" { + // This is all sort of trivially seen by looking at the code but + // we want to make sure we never regress this. + var mods: KittyMods = .{}; + try testing.expectEqual(@as(u9, 1), mods.seqInt()); + + mods = .{ .shift = true }; + try testing.expectEqual(@as(u9, 2), mods.seqInt()); + + mods = .{ .alt = true }; + try testing.expectEqual(@as(u9, 3), mods.seqInt()); + + mods = .{ .ctrl = true }; + try testing.expectEqual(@as(u9, 5), mods.seqInt()); + + mods = .{ .alt = true, .shift = true }; + try testing.expectEqual(@as(u9, 4), mods.seqInt()); + + mods = .{ .ctrl = true, .shift = true }; + try testing.expectEqual(@as(u9, 6), mods.seqInt()); + + mods = .{ .alt = true, .ctrl = true }; + try testing.expectEqual(@as(u9, 7), mods.seqInt()); + + mods = .{ .alt = true, .ctrl = true, .shift = true }; + try testing.expectEqual(@as(u9, 8), mods.seqInt()); + } +}; + +/// Represents a kitty key sequence and has helpers for encoding it. +/// The sequence from the Kitty specification: +/// +/// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u +const KittySequence = struct { + key: u21, + final: u8, + mods: KittyMods = .{}, + event: Event = .none, + alternates: [2]?u21 = .{ null, null }, + text: []const u8 = "", + + /// Values for the event code (see "event-type" in above comment). + /// Note that Kitty omits the ":1" for the press event but other + /// terminals include it. We'll include it. + const Event = enum(u2) { + none = 0, + press = 1, + repeat = 2, + release = 3, + }; + + pub fn encode( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + if (self.final == 'u' or self.final == '~') return try self.encodeFull(writer); + return try self.encodeSpecial(writer); + } + + fn encodeFull( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // Key section + try writer.print("\x1B[{d}", .{self.key}); + // Write our alternates + if (self.alternates[0]) |shifted| try writer.print(":{d}", .{shifted}); + if (self.alternates[1]) |base| { + if (self.alternates[0] == null) { + try writer.print("::{d}", .{base}); + } else { + try writer.print(":{d}", .{base}); + } + } + + // Mods and events section + const mods = self.mods.seqInt(); + var emit_prior = false; + if (self.event != .none and self.event != .press) { + try writer.print( + ";{d}:{d}", + .{ mods, @intFromEnum(self.event) }, + ); + emit_prior = true; + } else if (mods > 1) { + try writer.print(";{d}", .{mods}); + emit_prior = true; + } + + // Text section + if (self.text.len > 0) text: { + const view = std.unicode.Utf8View.init(self.text) catch { + // Assume invalid UTF-8 means we have no text. + break :text; + }; + var it = view.iterator(); + var count: usize = 0; + while (it.nextCodepoint()) |cp| { + // If the codepoint is non-printable ASCII character, skip. + if (isControl(cp)) continue; + + // We need to add our ";". We need to add two if we didn't emit + // the modifier section. We only do this initially. + if (count == 0) { + if (!emit_prior) try writer.writeByte(';'); + try writer.writeByte(';'); + } else { + try writer.writeByte(':'); + } + + try writer.print("{d}", .{cp}); + count += 1; + } + } + + try writer.print("{c}", .{self.final}); + } + + fn encodeSpecial( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const mods = self.mods.seqInt(); + if (self.event != .none) { + return try writer.print("\x1B[1;{d}:{d}{c}", .{ + mods, + @intFromEnum(self.event), + self.final, + }); + } + + if (mods > 1) { + return try writer.print("\x1B[1;{d}{c}", .{ + mods, + self.final, + }); + } + + return try writer.print("\x1B[{c}", .{self.final}); + } +}; + +test "KittySequence: backspace" { + var buf: [128]u8 = undefined; + + // Plain + { + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ .key = 127, .final = 'u' }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127u", writer.buffered()); + } + + // Release event + { + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ .key = 127, .final = 'u', .event = .release }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;1:3u", writer.buffered()); + } + + // Shift + { + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ + .key = 127, + .final = 'u', + .mods = .{ .shift = true }, + }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;2u", writer.buffered()); + } +} + +test "KittySequence: text" { + var buf: [128]u8 = undefined; + + // Plain + { + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ + .key = 127, + .final = 'u', + .text = "A", + }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;;65u", writer.buffered()); + } + + // Release + { + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ + .key = 127, + .final = 'u', + .event = .release, + .text = "A", + }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;1:3;65u", writer.buffered()); + } + + // Shift + { + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ + .key = 127, + .final = 'u', + .mods = .{ .shift = true }, + .text = "A", + }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;2;65u", writer.buffered()); + } +} +// +test "KittySequence: text with control characters" { + var buf: [128]u8 = undefined; + + // By itself + { + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ + .key = 127, + .final = 'u', + .text = "\n", + }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1b[127u", writer.buffered()); + } + + // With other printables + { + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ + .key = 127, + .final = 'u', + .text = "A\n", + }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1b[127;;65u", writer.buffered()); + } +} +// +test "KittySequence: special no mods" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ .key = 1, .final = 'A' }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[A", writer.buffered()); +} + +test "KittySequence: special mods only" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ .key = 1, .final = 'A', .mods = .{ .shift = true } }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[1;2A", writer.buffered()); +} + +test "KittySequence: special mods and event" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var seq: KittySequence = .{ + .key = 1, + .final = 'A', + .event = .release, + .mods = .{ .shift = true }, + }; + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[1;2:3A", writer.buffered()); +} + +test "kitty: plain text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{}, + .utf8 = "abcd", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("abcd", writer.buffered()); +} + +test "kitty: repeat with just disambiguate" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .action = .repeat, + .mods = .{}, + .utf8 = "a", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("a", writer.buffered()); +} +// +test "kitty: enter, backspace, tab" { + var buf: [128]u8 = undefined; + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .enter, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\r", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .backspace, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x7f", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .tab, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\t", writer.buffered()); + } + + // No release events if "report_all" is not set + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ .disambiguate = true, .report_events = true }, + }); + try testing.expectEqualStrings("", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ .disambiguate = true, .report_events = true }, + }); + try testing.expectEqualStrings("", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ .disambiguate = true, .report_events = true }, + }); + try testing.expectEqualStrings("", writer.buffered()); + } + + // Release events if "report_all" is set + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_all = true, + }, + }); + try testing.expectEqualStrings("\x1b[13;1:3u", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_all = true, + }, + }); + try testing.expectEqualStrings("\x1b[127;1:3u", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_all = true, + }, + }); + try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered()); + } +} + +test "kitty: shift+backspace emits CSI u" { + // Backspace with shift modifier should emit CSI u sequence, not raw 0x7F. + // This is important for programs that want to distinguish shift+backspace. + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[127;2u", writer.buffered()); +} + +test "kitty: shift+enter emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[13;2u", writer.buffered()); +} + +test "kitty: shift+tab emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); +} + +test "kitty: enter with all flags" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .enter, .mods = .{}, .utf8 = "" }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }, + }); + const actual = writer.buffered(); + try testing.expectEqualStrings("[13u", actual[1..]); +} +// +test "kitty: ctrl with all flags" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "" }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }, + }); + const actual = writer.buffered(); + try testing.expectEqualStrings("[57442;5u", actual[1..]); +} + +test "kitty: ctrl release with ctrl mod set" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .control_left, + .mods = .{ .ctrl = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }, + }); + const actual = writer.buffered(); + try testing.expectEqualStrings("[57442;5:3u", actual[1..]); +} + +test "kitty: delete" { + var buf: [128]u8 = undefined; + { + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .delete, .mods = .{}, .utf8 = "\x7F" }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[3~", writer.buffered()); + } +} + +test "kitty: composing with no modifier" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .composing = true, + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "kitty: composing with modifier" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{ .shift = true }, + .composing = true, + }, .{ + .kitty_flags = .{ .disambiguate = true, .report_all = true }, + }); + try testing.expectEqualStrings("\x1b[57441;2u", writer.buffered()); +} + +test "kitty: composed text with report all" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .unidentified, + .mods = .{}, + .utf8 = "\xc3\xbb", // û + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("\xc3\xbb", writer.buffered()); +} + +test "kitty: shift+a on US keyboard" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .utf8 = "A", + .unshifted_codepoint = 97, // lowercase A + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_alternates = true, + }, + }); + try testing.expectEqualStrings("\x1b[97:65;2u", writer.buffered()); +} + +test "kitty: matching unshifted codepoint" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .utf8 = "A", + .unshifted_codepoint = 65, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_alternates = true, + }, + }); + // WARNING: This is not a valid encoding. This is a hypothetical encoding + // just to test that our logic is correct around matching unshifted + // codepoints. We get an alternate here because the unshifted_codepoint does + // not match the base key + try testing.expectEqualStrings("\x1b[65::97;2u", writer.buffered()); +} + +test "kitty: report alternates with caps" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .caps_lock = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("\x1b[106;65;74u", writer.buffered()); +} + +test "kitty: report alternates colon (shift+';')" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .shift = true }, + .utf8 = ":", + .unshifted_codepoint = ';', + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("\x1b[59:58;2;58u", writer.buffered()); +} + +test "kitty: report alternates with ru layout" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{}, + .utf8 = "ч", + .unshifted_codepoint = 1095, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("\x1b[1095::59;;1095u", writer.buffered()); +} + +test "kitty: report alternates with ru layout shifted" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .shift = true }, + .utf8 = "Ч", + .unshifted_codepoint = 1095, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("\x1b[1095:1063:59;2;1063u", writer.buffered()); +} + +test "kitty: report alternates with ru layout caps lock" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .caps_lock = true }, + .utf8 = "Ч", + .unshifted_codepoint = 1095, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("\x1b[1095::59;65;1063u", writer.buffered()); +} + +test "kitty: report alternates with hu layout release" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .bracket_left, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 337, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + .report_events = true, + }, + }); + const actual = writer.buffered(); + try testing.expectEqualStrings("[337::91;5:3u", actual[1..]); +} + +// macOS generates utf8 text for arrow keys. +test "kitty: up arrow with utf8" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .arrow_up, + .mods = .{}, + .utf8 = &.{30}, + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[A", writer.buffered()); +} + +test "kitty: shift+tab" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", // tab + }, .{ + .kitty_flags = .{ .disambiguate = true, .report_alternates = true }, + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); +} + +test "kitty: left shift" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{}, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true, .report_alternates = true }, + }); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "kitty: left shift with report all" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{}, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true, .report_all = true }, + }); + try testing.expectEqualStrings("\x1b[57441u", writer.buffered()); +} + +test "kitty: report associated with alt text on macOS with option" { + if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{ .alt = true }, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + .macos_option_as_alt = .false, + }); + try testing.expectEqualStrings("\x1b[119;3;8721u", writer.buffered()); +} + +test "kitty: report associated with alt text on macOS with alt" { + if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + + { + // With Alt modifier + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{ .alt = true }, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1b[119;3u", writer.buffered()); + } + + { + // Without Alt modifier + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{}, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1b[119;;8721u", writer.buffered()); + } +} + +test "kitty: report associated with modifiers" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .ctrl = true }, + .utf8 = "j", + .unshifted_codepoint = 106, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("\x1b[106;5u", writer.buffered()); +} + +test "kitty: report associated" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .shift = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("\x1b[106:74;2;74u", writer.buffered()); +} + +test "kitty: report associated on release" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .key_j, + .mods = .{ .shift = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + .report_events = true, + }, + }); + const actual = writer.buffered(); + try testing.expectEqualStrings("[106:74;2:3u", actual[1..]); +} + +test "kitty: alternates omit control characters" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .delete, + .mods = .{}, + .utf8 = &.{0x7F}, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_alternates = true, + .report_all = true, + }, + }); + try testing.expectEqualStrings("\x1b[3~", writer.buffered()); +} + +test "kitty: enter with utf8 (dead key state)" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_alternates = true, + .report_all = true, + }, + }); + try testing.expectEqualStrings("A", writer.buffered()); +} + +test "kitty: keypad number" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .utf8 = "1", + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }, + }); + const actual = writer.buffered(); + try testing.expectEqualStrings("[57400;;49u", actual[1..]); +} + +test "kitty: backspace with utf8 (dead key state)" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "legacy: backspace with utf8 (dead key state)" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "legacy: enter with utf8 (dead key state)" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .enter, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("A", writer.buffered()); +} + +test "legacy: esc with utf8 (dead key state)" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .escape, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("A", writer.buffered()); +} + +test "legacy: ctrl+shift+minus (underscore on US)" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .minus, + .mods = .{ .ctrl = true, .shift = true }, + .utf8 = "_", + }, .{}); + try testing.expectEqualStrings("\x1F", writer.buffered()); +} + +test "legacy: ctrl+alt+c" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .mods = .{ .ctrl = true, .alt = true }, + .utf8 = "c", + }, .{}); + try testing.expectEqualStrings("\x1b\x03", writer.buffered()); +} + +test "legacy: alt+c" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .utf8 = "c", + .mods = .{ .alt = true }, + }, .{ + .alt_esc_prefix = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1Bc", writer.buffered()); +} + +test "legacy: alt+e only unshifted" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_e, + .unshifted_codepoint = 'e', + .mods = .{ .alt = true }, + }, .{ + .alt_esc_prefix = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1Be", writer.buffered()); +} + +test "legacy: alt+x macos" { + if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .utf8 = "≈", + .unshifted_codepoint = 'c', + .mods = .{ .alt = true }, + }, .{ + .alt_esc_prefix = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1Bc", writer.buffered()); +} + +test "legacy: shift+alt+. macos" { + if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .period, + .utf8 = ">", + .unshifted_codepoint = '.', + .mods = .{ .alt = true, .shift = true }, + }, .{ + .alt_esc_prefix = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1B>", writer.buffered()); +} + +test "legacy: alt+ф" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_f, + .utf8 = "ф", + .mods = .{ .alt = true }, + }, .{ + .alt_esc_prefix = true, + }); + try testing.expectEqualStrings("ф", writer.buffered()); +} + +test "legacy: ctrl+c" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .mods = .{ .ctrl = true }, + .utf8 = "c", + }, .{}); + try testing.expectEqualStrings("\x03", writer.buffered()); +} + +test "legacy: ctrl+space" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .space, + .mods = .{ .ctrl = true }, + .utf8 = " ", + }, .{}); + try testing.expectEqualStrings("\x00", writer.buffered()); +} + +test "legacy: ctrl+shift+backspace" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .mods = .{ .ctrl = true, .shift = true }, + }, .{}); + try testing.expectEqualStrings("\x08", writer.buffered()); +} + +test "legacy: ctrl+shift+char with modify other state 2" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_h, + .mods = .{ .ctrl = true, .shift = true }, + .utf8 = "H", + }, .{ + .modify_other_keys_state_2 = true, + }); + try testing.expectEqualStrings("\x1b[27;6;72~", 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); + try legacy(&writer, .{ + .key = .key_h, + .mods = .{ .ctrl = true, .shift = true }, + .consumed_mods = .{ .shift = true }, + .utf8 = "H", + }, .{ + .modify_other_keys_state_2 = true, + }); + try testing.expectEqualStrings("\x1b[27;6;72~", writer.buffered()); +} + +test "legacy: alt+digit with modify other state 2" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .digit_8, + .mods = .{ .alt = true }, + .consumed_mods = .{}, + .utf8 = "8", + }, .{ + .modify_other_keys_state_2 = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1b[27;3;56~", writer.buffered()); +} + +test "legacy: alt+digit with modify other state 2 and macos-option-as-alt = false" { + if (comptime builtin.os.tag != .macos) return error.SkipZigTest; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .digit_8, + .mods = .{ .alt = true }, + .consumed_mods = .{ .alt = true }, + .utf8 = "[", // common translation of option+8 with European keyboard layouts + }, .{ + .modify_other_keys_state_2 = true, + .macos_option_as_alt = .false, + }); + try testing.expectEqualStrings("[", writer.buffered()); +} + +test "legacy: fixterm awkward letters" { + var buf: [128]u8 = undefined; + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_i, + .mods = .{ .ctrl = true }, + .utf8 = "i", + }, .{}); + try testing.expectEqualStrings("\x1b[105;5u", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_m, + .mods = .{ .ctrl = true }, + .utf8 = "m", + }, .{}); + try testing.expectEqualStrings("\x1b[109;5u", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .bracket_left, + .mods = .{ .ctrl = true }, + .utf8 = "[", + }, .{}); + try testing.expectEqualStrings("\x1b[91;5u", writer.buffered()); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .digit_2, + .mods = .{ .ctrl = true, .shift = true }, + .utf8 = "@", + .unshifted_codepoint = '2', + }, .{}); + try testing.expectEqualStrings("\x1b[64;5u", writer.buffered()); + } +} + +// These tests apply Kitty's behavior to CSIu where ctrl+shift+letter +// is sent as the unshifted letter with the shift modifier present. +test "legacy: ctrl+shift+letter ascii" { + var buf: [128]u8 = undefined; + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_m, + .mods = .{ .ctrl = true, .shift = true }, + .utf8 = "M", + .unshifted_codepoint = 'm', + }, .{}); + try testing.expectEqualStrings("\x1b[109;6u", writer.buffered()); + } +} + +test "legacy: shift+function key should use all mods" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .arrow_up, + .mods = .{ .shift = true }, + .consumed_mods = .{ .shift = true }, + }, .{}); + try testing.expectEqualStrings("\x1b[1;2A", writer.buffered()); +} + +test "legacy: keypad enter" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_enter, + .mods = .{}, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\r", writer.buffered()); +} + +test "legacy: keypad 1" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .consumed_mods = .{}, + .utf8 = "1", + }, .{}); + try testing.expectEqualStrings("1", writer.buffered()); +} + +test "legacy: keypad 1 with application keypad" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ + .keypad_key_application = true, + }); + try testing.expectEqualStrings("\x1bOq", writer.buffered()); +} + +test "legacy: keypad 1 with application keypad and numlock" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{ .num_lock = true }, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ + .keypad_key_application = true, + }); + try testing.expectEqualStrings("\x1bOq", writer.buffered()); +} + +test "legacy: keypad 1 with application keypad and numlock ignore" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{ .num_lock = false }, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ + .keypad_key_application = true, + .ignore_keypad_with_numlock = true, + }); + try testing.expectEqualStrings("1", writer.buffered()); +} + +test "legacy: f1" { + var buf: [128]u8 = undefined; + + // F1 + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f1, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5P", writer.buffered()); + } + + // F2 + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f2, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5Q", writer.buffered()); + } + + // F3 + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f3, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[13;5~", writer.buffered()); + } + + // F4 + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f4, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5S", writer.buffered()); + } + + // F5 uses new encoding + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f5, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[15;5~", writer.buffered()); + } +} + +test "legacy: left_shift+tab" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .tab, + .mods = .{ + .shift = true, + .sides = .{ .shift = .left }, + }, + }, .{}); + try testing.expectEqualStrings("\x1b[Z", writer.buffered()); +} + +test "legacy: right_shift+tab" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .tab, + .mods = .{ + .shift = true, + .sides = .{ .shift = .right }, + }, + }, .{}); + try testing.expectEqualStrings("\x1b[Z", writer.buffered()); +} + +test "legacy: hu layout ctrl+ő sends proper codepoint" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .bracket_left, + .mods = .{ .ctrl = true }, + .utf8 = "ő", + .unshifted_codepoint = 337, + }, .{}); + const actual = writer.buffered(); + try testing.expectEqualStrings("[337;5u", actual[1..]); +} + +test "legacy: super-only on macOS with text" { + if (comptime builtin.os.tag != .macos) return error.SkipZigTest; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_b, + .utf8 = "b", + .mods = .{ .super = true }, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "legacy: super and other mods on macOS with text" { + if (comptime builtin.os.tag != .macos) return error.SkipZigTest; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_b, + .utf8 = "B", + .mods = .{ .super = true, .shift = true }, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "legacy: backspace with DEL utf8" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .utf8 = &.{0x7F}, + .unshifted_codepoint = 0x08, + }, .{}); + try testing.expectEqualStrings("\x7F", writer.buffered()); +} + +test "ctrlseq: normal ctrl c" { + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .ctrl = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: normal ctrl c, right control" { + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: alt should be allowed" { + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .alt = true, .ctrl = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: no ctrl does nothing" { + try testing.expect(ctrlSeq(.unidentified, "c", 'c', .{}) == null); +} + +test "ctrlseq: shifted non-character" { + const seq = ctrlSeq(.unidentified, "_", '-', .{ .ctrl = true, .shift = true }); + try testing.expectEqual(@as(u8, 0x1F), seq.?); +} + +test "ctrlseq: caps ascii letter" { + const seq = ctrlSeq(.unidentified, "C", 'c', .{ .ctrl = true, .caps_lock = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: shift does not generate ctrl seq" { + try testing.expect(ctrlSeq(.unidentified, "C", 'c', .{ .shift = true }) == null); + try testing.expect(ctrlSeq(.unidentified, "C", 'c', .{ .shift = true, .ctrl = true }) == null); +} + +test "ctrlseq: russian ctrl c" { + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: russian shifted ctrl c" { + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true, .shift = true }); + try testing.expect(seq == null); +} + +test "ctrlseq: russian alt ctrl c" { + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true, .alt = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: right ctrl c" { + const seq = ctrlSeq(.key_c, "с", 'c', .{ + .ctrl = true, + .sides = .{ .ctrl = .right }, + }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} |
