diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/input | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/input')
| -rw-r--r-- | vendor/libghostty-vt/src/input/Binding.zig | 4847 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/KeymapDarwin.zig | 289 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/KeymapNoop.zig | 39 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/Link.zig | 79 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/command.zig | 739 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/config.zig | 8 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/function_keys.zig | 312 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/helpgen_actions.zig | 112 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/key.zig | 878 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/key_encode.zig | 2424 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/key_mods.zig | 914 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/keyboard.zig | 58 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/keycodes.zig | 673 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/kitty.zig | 134 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/mouse.zig | 110 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/mouse_encode.zig | 781 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/input/paste.zig | 228 |
17 files changed, 12625 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/input/Binding.zig b/vendor/libghostty-vt/src/input/Binding.zig new file mode 100644 index 00000000..62a4e39a --- /dev/null +++ b/vendor/libghostty-vt/src/input/Binding.zig @@ -0,0 +1,4847 @@ +//! A binding maps some input trigger to an action. When the trigger +//! occurs, the action is performed. +const Binding = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../quirks.zig").inlineAssert; +const build_config = @import("../build_config.zig"); +const uucode = @import("uucode"); +const EntryFormatter = @import("../config/formatter.zig").EntryFormatter; +const deepEqual = @import("../datastruct/comparison.zig").deepEqual; +const key = @import("key.zig"); +const key_mods = @import("key_mods.zig"); +const KeyEvent = key.KeyEvent; + +/// The trigger that needs to be performed to execute the action. +trigger: Trigger, + +/// The action to take if this binding matches +action: Action, + +/// Boolean flags that can be set per binding. +flags: Flags = .{}, + +pub const Error = error{ + InvalidFormat, + InvalidAction, +}; + +/// Flags the full binding-scoped flags that can be set per binding. +pub const Flags = packed struct { + /// True if this binding should consume the input when the + /// action is triggered. + consumed: bool = true, + + /// True if this binding should be forwarded to all active surfaces + /// in the application. + all: bool = false, + + /// True if this binding is global. Global bindings should work system-wide + /// and not just while Ghostty is focused. This may not work on all platforms. + /// See the keybind config documentation for more information. + global: bool = false, + + /// True if this binding should only be triggered if the action can be + /// performed. If the action can't be performed then the binding acts as + /// if it doesn't exist. + performable: bool = false, + + /// C type + pub const C = u8; + + /// Converts this to a C-compatible value. + /// + /// Sync with ghostty.h for enums. + pub fn cval(self: Flags) C { + const Backing = @typeInfo(Flags).@"struct".backing_integer.?; + return @as(Backing, @bitCast(self)); + } + + test "cval" { + const testing = std.testing; + try testing.expectEqual(@as(u8, 0b0001), (Flags{}).cval()); + try testing.expectEqual(@as(u8, 0b0000), (Flags{ .consumed = false }).cval()); + try testing.expectEqual(@as(u8, 0b0011), (Flags{ .all = true }).cval()); + try testing.expectEqual(@as(u8, 0b0101), (Flags{ .global = true }).cval()); + try testing.expectEqual(@as(u8, 0b1001), (Flags{ .performable = true }).cval()); + try testing.expectEqual(@as(u8, 0b1111), (Flags{ .consumed = true, .all = true, .global = true, .performable = true }).cval()); + } +}; + +/// Full binding parser. The binding parser is implemented as an iterator +/// which yields elements to support multi-key sequences without allocation. +pub const Parser = struct { + trigger_it: SequenceIterator, + action: Action, + flags: Flags = .{}, + chain: bool, + + pub const Elem = union(enum) { + /// A leader trigger in a sequence. + leader: Trigger, + + /// The final trigger and action in a sequence. + binding: Binding, + + /// A chained action `chain=<action>` that should be appended + /// to the previous binding. Note that any action is parsed, including + /// invalid actions for chains such as `unbind`. We expect downstream + /// consumers to validate that the action is valid for chaining. + chain: Action, + }; + + pub fn init(raw_input: []const u8) Error!Parser { + const flags, const start_idx = try parseFlags(raw_input); + const input = raw_input[start_idx..]; + + // Find the equal sign. This is more complicated than it seems on + // the surface because we need to ignore equal signs that are + // part of the trigger. + const eql_idx: usize = eql: { + // TODO: We should change this parser into a real state machine + // based parser that parses the trigger fully, then yields the + // action after. The loop below is a total mess. + var offset: usize = 0; + while (std.mem.indexOfScalar( + u8, + input[offset..], + '=', + )) |offset_idx| { + // Find: '=+ctrl' or '==action' + const idx = offset + offset_idx; + if (idx < input.len - 1 and + (input[idx + 1] == '+' or + input[idx + 1] == '=')) + { + offset += offset_idx + 1; + continue; + } + + // Looks like the real equal sign. + break :eql idx; + } + + return Error.InvalidFormat; + }; + + // Detect chains. Chains must not have flags. + const chain = std.mem.eql(u8, input[0..eql_idx], "chain"); + if (chain and start_idx > 0) return Error.InvalidFormat; + + // Sequence iterator goes up to the equal, action is after. We can + // parse the action now. + return .{ + .trigger_it = .{ + // This is kind of hacky but we put a dummy trigger + // for chained inputs. The `next` will never yield this + // because we have chain set. When we find a nicer way to + // do this we can remove it, the e2e is tested. + .input = if (chain) "a" else input[0..eql_idx], + }, + .action = try .parse(input[eql_idx + 1 ..]), + .flags = flags, + .chain = chain, + }; + } + + fn parseFlags(raw_input: []const u8) Error!struct { Flags, usize } { + var flags: Flags = .{}; + + var start_idx: usize = 0; + var input: []const u8 = raw_input; + while (true) { + // Find the next prefix + const idx = std.mem.indexOf(u8, input, ":") orelse break; + const prefix = input[0..idx]; + + // If the prefix is one of our flags then set it. + if (std.mem.eql(u8, prefix, "all")) { + if (flags.all) return Error.InvalidFormat; + flags.all = true; + } else if (std.mem.eql(u8, prefix, "global")) { + if (flags.global) return Error.InvalidFormat; + flags.global = true; + } else if (std.mem.eql(u8, prefix, "unconsumed")) { + if (!flags.consumed) return Error.InvalidFormat; + flags.consumed = false; + } else if (std.mem.eql(u8, prefix, "performable")) { + if (flags.performable) return Error.InvalidFormat; + flags.performable = true; + } else { + // If we don't recognize the prefix then we're done. We + // let any unknown prefix fallthrough to trigger-specific + // parsing in case there are trigger-specific prefixes + // (none currently but historically there was `physical:` + // at one point). Breaking here lets us always implement new + // prefixes. + break; + } + + // Move past the prefix + start_idx += idx + 1; + input = input[idx + 1 ..]; + } + + return .{ flags, start_idx }; + } + + pub fn next(self: *Parser) Error!?Elem { + // Get our trigger. If we're out of triggers then we're done. + const trigger = (try self.trigger_it.next()) orelse return null; + + // If this is our last trigger then it is our final binding. + if (!self.trigger_it.done()) { + // Global/all bindings can't be sequences + if (self.flags.global or self.flags.all) return error.InvalidFormat; + return .{ .leader = trigger }; + } + + // If we're a chain then return it as-is. + if (self.chain) return .{ .chain = self.action }; + + // Out of triggers, yield the final action. + return .{ .binding = .{ + .trigger = trigger, + .action = self.action, + .flags = self.flags, + } }; + } + + pub fn reset(self: *Parser) void { + self.trigger_it.i = 0; + } +}; + +/// An iterator that yields each trigger in a sequence of triggers. For +/// example, the sequence "ctrl+a>ctrl+b" would yield "ctrl+a" and then +/// "ctrl+b". The iterator approach allows us to parse a sequence of +/// triggers without allocations. +const SequenceIterator = struct { + /// The input of triggers. This is expected to be ONLY triggers. Things + /// like the "unconsumed:" prefix or action must be stripped before + /// passing to this iterator. + input: []const u8, + i: usize = 0, + + /// Returns the next trigger in the sequence if there is no parsing error. + pub fn next(self: *SequenceIterator) Error!?Trigger { + if (self.done()) return null; + const rem = self.input[self.i..]; + const idx = std.mem.indexOf(u8, rem, ">") orelse rem.len; + defer self.i += idx + 1; + return try .parse(rem[0..idx]); + } + + /// Returns true if there are no more triggers to parse. + pub fn done(self: *const SequenceIterator) bool { + return self.i > self.input.len; + } +}; + +/// Parse a single, non-sequenced binding. To support sequences you must +/// use parse. This is a convenience function for single bindings aimed +/// primarily at tests. +/// +/// This doesn't support `chain` either, since chaining requires some +/// stateful concept of a prior binding. +fn parseSingle(raw_input: []const u8) (Error || error{ + UnexpectedChain, + UnexpectedSequence, +})!Binding { + var p = try Parser.init(raw_input); + const elem = (try p.next()) orelse return Error.InvalidFormat; + return switch (elem) { + .leader => error.UnexpectedSequence, + .binding => elem.binding, + .chain => error.UnexpectedChain, + }; +} + +/// Returns true if lhs should be sorted before rhs +pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { + const lhs_count: usize = blk: { + var count: usize = 0; + if (lhs.trigger.mods.super) count += 1; + if (lhs.trigger.mods.ctrl) count += 1; + if (lhs.trigger.mods.shift) count += 1; + if (lhs.trigger.mods.alt) count += 1; + break :blk count; + }; + const rhs_count: usize = blk: { + var count: usize = 0; + if (rhs.trigger.mods.super) count += 1; + if (rhs.trigger.mods.ctrl) count += 1; + if (rhs.trigger.mods.shift) count += 1; + if (rhs.trigger.mods.alt) count += 1; + break :blk count; + }; + + if (lhs_count != rhs_count) + return lhs_count > rhs_count; + + if (lhs.trigger.mods.int() != rhs.trigger.mods.int()) + return lhs.trigger.mods.int() > rhs.trigger.mods.int(); + + const lhs_key: c_int = blk: { + switch (lhs.trigger.key) { + .physical => break :blk @intFromEnum(lhs.trigger.key.physical), + .unicode => break :blk @intCast(lhs.trigger.key.unicode), + } + }; + const rhs_key: c_int = blk: { + switch (rhs.trigger.key) { + .physical => break :blk @intFromEnum(rhs.trigger.key.physical), + .unicode => break :blk @intCast(rhs.trigger.key.unicode), + } + }; + + return lhs_key < rhs_key; +} + +/// The set of actions that a keybinding can take. +pub const Action = union(enum) { + /// Ignore this key combination. + /// + /// Ghostty will not process this combination nor forward it to the child + /// process within the terminal, but it may still be processed by the OS or + /// other applications. + ignore, + + /// Unbind a previously bound key binding. + /// + /// This cannot unbind bindings that were not bound by Ghostty or the user + /// (e.g. bindings set by the OS or some other application). + unbind, + + /// Send a CSI sequence. + /// + /// The value should be the CSI sequence without the CSI header (`ESC [` or + /// `\x1b[`). + /// + /// For example, `csi:0m` can be sent to reset all styles of the current text. + csi: []const u8, + + /// Send an `ESC` sequence. + esc: []const u8, + + /// Send the specified text. + /// + /// Uses Zig string literal syntax. This is currently not validated. + /// If the text is invalid (i.e. contains an invalid escape sequence), + /// the error will currently only show up in logs. + text: []const u8, + + /// Send data to the pty depending on whether cursor key mode is enabled + /// (`application`) or disabled (`normal`). + cursor_key: CursorKey, + + /// Reset the terminal. + /// + /// This can fix a lot of issues when a running program puts the terminal + /// into a broken state, equivalent to running the `reset` command. + /// + /// If you do this while in a TUI program such as vim, this may break + /// the program. If you do this while in a shell, you may have to press + /// enter after to get a new prompt. + reset, + + /// Copy the selected text to the clipboard. + copy_to_clipboard: CopyToClipboard, + + /// Paste the contents of the default clipboard. + paste_from_clipboard, + + /// Paste the contents of the selection clipboard. + paste_from_selection, + + /// If there is a URL under the cursor, copy it to the default clipboard. + copy_url_to_clipboard, + + /// Copy the terminal title to the clipboard. If the terminal title is not + /// set or is empty this has no effect. + copy_title_to_clipboard, + + /// Increase the font size by the specified amount in points (pt). + /// + /// For example, `increase_font_size:1.5` will increase the font size + /// by 1.5 points. + increase_font_size: f32, + + /// Decrease the font size by the specified amount in points (pt). + /// + /// For example, `decrease_font_size:1.5` will decrease the font size + /// by 1.5 points. + decrease_font_size: f32, + + /// Reset the font size to the original configured size. + reset_font_size, + + /// Set the font size to the specified size in points (pt). + /// + /// For example, `set_font_size:14.5` will set the font size + /// to 14.5 points. + set_font_size: f32, + + /// Start a search for the given text. If the text is empty, then + /// the search is canceled. A canceled search will not disable any GUI + /// elements showing search. For that, the explicit end_search binding + /// should be used. + /// + /// If a previous search is active, it is replaced. + search: []const u8, + + /// Start a search for the current text selection. If there is no + /// selection, this does nothing. If a search is already active, this + /// changes the search terms. + search_selection, + + /// Navigate the search results. If there is no active search, this + /// is not performed. + navigate_search: NavigateSearch, + + /// Start a search if it isn't started already. This doesn't set any + /// search terms, but opens the UI for searching. + start_search, + + /// End the current search if any and hide any GUI elements. + end_search, + + /// Clear the screen and all scrollback. + clear_screen, + + /// Select all text on the screen. + select_all, + + /// Scroll to the top of the screen. + scroll_to_top, + + /// Scroll to the bottom of the screen. + scroll_to_bottom, + + /// Scroll to the selected text. + scroll_to_selection, + + /// Scroll to the given absolute row in the screen with 0 being + /// the first row. + scroll_to_row: usize, + + /// Scroll the screen up by one page. + scroll_page_up, + + /// Scroll the screen down by one page. + scroll_page_down, + + /// Scroll the screen by the specified fraction of a page. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + /// + /// For example, `scroll_page_fractional:0.5` would scroll the screen + /// downwards by half a page, while `scroll_page_fractional:-1.5` would + /// scroll it upwards by one and a half pages. + scroll_page_fractional: f32, + + /// Scroll the screen by the specified amount of lines. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + /// + /// For example, `scroll_page_lines:3` would scroll the screen downwards + /// by 3 lines, while `scroll_page_lines:-10` would scroll it upwards by 10 + /// lines. + scroll_page_lines: i16, + + /// Adjust the current selection in the given direction or position, + /// relative to the cursor. + /// + /// WARNING: This does not create a new selection, and does nothing when + /// there currently isn't one. + /// + /// Valid arguments are: + /// + /// - `left`, `right` + /// + /// Adjust the selection one cell to the left or right respectively. + /// + /// - `up`, `down` + /// + /// Adjust the selection one line upwards or downwards respectively. + /// + /// - `page_up`, `page_down` + /// + /// Adjust the selection one page upwards or downwards respectively. + /// + /// - `home`, `end` + /// + /// Adjust the selection to the top-left or the bottom-right corner + /// of the screen respectively. + /// + /// - `beginning_of_line`, `end_of_line` + /// + /// Adjust the selection to the beginning or the end of the line + /// respectively. + /// + adjust_selection: AdjustSelection, + + /// Jump the viewport forward or back by the given number of prompts. + /// + /// Requires shell integration. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + jump_to_prompt: i16, + + /// Write the entire scrollback into a temporary file with the specified + /// action. The action determines what to do with the filepath. + /// + /// Valid actions are: + /// + /// - `copy` + /// + /// Copy the file path into the clipboard. + /// + /// - `paste` + /// + /// Paste the file path into the terminal. + /// + /// - `open` + /// + /// Open the file in the default OS editor for text files. + /// + /// The default OS editor is determined by using `open` on macOS + /// and `xdg-open` on Linux. + /// + write_scrollback_file: WriteScreen, + + /// Write the contents of the screen into a temporary file with the + /// specified action. + /// + /// See `write_scrollback_file` for possible actions. + write_screen_file: WriteScreen, + + /// Write the currently selected text into a temporary file with the + /// specified action. + /// + /// See `write_scrollback_file` for possible actions. + /// + /// Does nothing when no text is selected. + write_selection_file: WriteScreen, + + /// Open a new window. + /// + /// If the application isn't currently focused, + /// this will bring it to the front. + new_window, + + /// Open a new tab. + new_tab, + + /// Go to the previous tab. + previous_tab, + + /// Go to the next tab. + next_tab, + + /// Go to the last tab. + last_tab, + + /// Go to the tab with the specific index, starting from 1. + /// + /// If the tab number is higher than the number of tabs, + /// this will go to the last tab. + goto_tab: usize, + + /// Moves a tab by a relative offset. + /// + /// Positive values move the tab forwards, and negative values move it + /// backwards. If the new position is out of bounds, it is wrapped around + /// cyclically within the tab list. + /// + /// For example, `move_tab:1` moves the tab one position forwards, and if + /// it was already the last tab in the list, it wraps around and becomes + /// the first tab in the list. Likewise, `move_tab:-1` moves the tab one + /// position backwards, and if it was the first tab, then it will become + /// the last tab. + move_tab: isize, + + /// Toggle the tab overview. + /// + /// This is only supported on Linux and when the system's libadwaita + /// version is 1.4 or newer. The current libadwaita version can be + /// found by running `ghostty +version`. + toggle_tab_overview, + + /// Change the title of the current focused surface via a pop-up prompt. + prompt_surface_title, + + /// Change the title of the current tab via a pop-up prompt. The + /// title set via this prompt overrides any title set by the terminal + /// and persists across focus changes within the tab. + prompt_tab_title, + + /// Set the title for the current focused surface. + /// + /// If the title is empty, the surface title is reset to an empty title. + set_surface_title: []const u8, + + /// Set the title for the current focused tab. + /// + /// If the title is empty, the tab title override is cleared. + set_tab_title: []const u8, + + /// Create a new split in the specified direction. + /// + /// Valid arguments: + /// + /// - `right`, `down`, `left`, `up` + /// + /// Creates a new split in the corresponding direction. + /// + /// - `auto` + /// + /// Creates a new split along the larger direction. + /// For example, if the parent split is currently wider than it is tall, + /// then a left-right split would be created, and vice versa. + /// + new_split: SplitDirection, + + /// Focus on a split either in the specified direction (`right`, `down`, + /// `left` and `up`), or in the adjacent split in the order of creation + /// (`previous` and `next`). + goto_split: SplitFocusDirection, + + /// Focus on either the previous window or the next one ('previous', 'next') + goto_window: GotoWindow, + + /// Zoom in or out of the current split. + /// + /// When a split is zoomed into, it will take up the entire space in + /// the current tab, hiding other splits. The tab or tab bar would also + /// reflect this by displaying an icon indicating the zoomed state. + toggle_split_zoom, + + /// Toggle read-only mode for the current surface. + /// + /// When a surface is in read-only mode: + /// - No input is sent to the PTY (mouse events, key encoding) + /// - Input can still be used at the terminal level to make selections, + /// copy/paste (keybinds), scroll, etc. + /// - Warn before quit is always enabled in this state even if an active + /// process is not running + toggle_readonly, + + /// Resize the current split in the specified direction and amount in + /// pixels. The two arguments should be joined with a comma (`,`), + /// like in `resize_split:up,10`. + resize_split: SplitResizeParameter, + + /// Equalize the size of all splits in the current window. + equalize_splits, + + /// Reset the window to the default size. The "default size" is the + /// size that a new window would be created with. This has no effect + /// if the window is fullscreen. + /// + /// Only implemented on macOS. + reset_window_size, + + /// Control the visibility of the terminal inspector. + /// + /// Valid arguments: `toggle`, `show`, `hide`. + inspector: InspectorMode, + + /// Show the GTK inspector. + /// + /// Has no effect on macOS. + show_gtk_inspector, + + /// Show the on-screen keyboard if one is present. + /// + /// Only implemented on Linux (GTK). On GNOME, the "Screen Keyboard" + /// accessibility feature must be turned on, which can be found under + /// Settings > Accessibility > Typing. Other platforms are as of now + /// untested. + show_on_screen_keyboard, + + /// Open the configuration file in the default OS editor. + /// + /// If your default OS editor isn't configured then this will fail. + /// Currently, any failures to open the configuration will show up only in + /// the logs. + open_config, + + /// Reload the configuration. + /// + /// The exact meaning depends on the app runtime in use, but this usually + /// involves re-reading the configuration file and applying any changes + /// Note that not all changes can be applied at runtime. + reload_config, + + /// Close the current "surface", whether that is a window, tab, split, etc. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. + close_surface, + + /// Close the current tab and all splits therein, close all other tabs, or + /// close every tab to the right of the current one depending on the mode. + /// + /// If the mode is not specified, defaults to closing the current tab. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. + close_tab: CloseTabMode, + + /// Close the current window and all tabs and splits therein. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. + close_window, + + /// Close all windows. + /// + /// WARNING: This action has been deprecated and has no effect on either + /// Linux or macOS. Users are instead encouraged to use `all:close_window` + /// instead. + close_all_windows, + + /// Maximize or unmaximize the current window. + /// + /// This has no effect on macOS as it does not have the concept of + /// maximized windows. + toggle_maximize, + + /// Fullscreen or unfullscreen the current window. + toggle_fullscreen, + + /// Toggle window decorations (titlebar, buttons, etc.) for the current window. + /// + /// Only implemented on Linux. + toggle_window_decorations, + + /// Toggle whether the terminal window should always float on top of other + /// windows even when unfocused. + /// + /// Terminal windows always start as normal (not float-on-top) windows. + /// + /// Only implemented on macOS. + toggle_window_float_on_top, + + /// Toggle secure input mode. + /// + /// This is used to prevent apps from monitoring your keyboard input + /// when entering passwords or other sensitive information. + /// + /// This applies to the entire application, not just the focused terminal. + /// You must manually untoggle it or quit Ghostty entirely to disable it. + /// + /// Only implemented on macOS, as this uses a built-in system API. + toggle_secure_input, + + /// Toggle mouse reporting on or off. + /// + /// When mouse reporting is disabled, mouse events will not be reported to + /// terminal applications even if they request it. This allows you to always + /// use the mouse for selection and other terminal UI interactions without + /// applications capturing mouse input. + /// + /// This can also be controlled via the `mouse-reporting` configuration + /// option. + toggle_mouse_reporting, + + /// Toggle the command palette. + /// + /// The command palette is a popup that lets you see what actions + /// you can perform, their associated keybindings (if any), a search bar + /// to filter the actions, and the ability to then execute the action. + /// + /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita + /// version can be found by running `ghostty +version`. + toggle_command_palette, + + /// Toggle the quick terminal. + /// + /// The quick terminal, also known as the "Quake-style" or drop-down + /// terminal, is a terminal window that appears on demand from a keybinding, + /// often sliding in from a screen edge such as the top. This is useful for + /// quick access to a terminal without having to open a new window or tab. + /// + /// The terminal state is preserved between appearances, so showing the + /// quick terminal after it was already hidden would display the same + /// window instead of creating a new one. + /// + /// As quick terminals are often useful when other windows are currently + /// focused, they are best used with *global* keybinds. For example, one + /// can define the following key bind to toggle the quick terminal from + /// anywhere within the system by pressing `` Cmd+` ``: + /// + /// ```ini + /// keybind = global:cmd+backquote=toggle_quick_terminal + /// ``` + /// + /// The quick terminal has some limitations: + /// + /// - Only one quick terminal instance can exist at a time. + /// + /// - Unlike normal terminal windows, the quick terminal will not be + /// restored when the application is restarted on systems that support + /// window restoration like macOS. + /// + /// - On Linux, the quick terminal is only supported on Wayland and not + /// X11, and only on Wayland compositors that support the `wlr-layer-shell-v1` + /// protocol. In practice, this means that only GNOME users would not be + /// able to use this feature. + /// + /// - On Linux, slide-in animations are only supported on KDE, and when + /// the "Sliding Popups" KWin plugin is enabled. + /// + /// If you do not have this plugin enabled, open System Settings > Apps + /// & Windows > Window Management > Desktop Effects, and enable the + /// plugin in the plugin list. Ghostty would then need to be restarted + /// fully for this to take effect. + /// + /// - Quick terminal tabs are only supported on Linux and not on macOS. + /// This is because tabs on macOS require a title bar. + /// + /// - On macOS, a fullscreened quick terminal will always be in non-native + /// fullscreen mode. This is a requirement due to how the quick terminal + /// is rendered. + /// + /// See the various configurations for the quick terminal in the + /// configuration file to customize its behavior. + toggle_quick_terminal, + + /// Show or hide all windows. If all windows become shown, we also ensure + /// Ghostty becomes focused. When hiding all windows, focus is yielded + /// to the next application as determined by the OS. + /// + /// Note: When the focused surface is fullscreen, this method does nothing. + /// + /// Only implemented on macOS. + toggle_visibility, + + /// Toggle the window background opacity between transparent and opaque. + /// + /// This does nothing when `background-opacity` is set to 1 or above. + /// + /// When `background-opacity` is less than 1, this action will either make + /// the window transparent or not depending on its current transparency state. + /// + /// Only implemented on macOS. + toggle_background_opacity, + + /// Check for updates. + /// + /// Only implemented on macOS. + check_for_updates, + + /// Undo the last undoable action for the focused surface or terminal, + /// if possible. This can undo actions such as closing tabs or + /// windows. + /// + /// Not every action in Ghostty can be undone or redone. The list + /// of actions support undo/redo is currently limited to: + /// + /// - New window, close window + /// - New tab, close tab + /// - New split, close split + /// + /// All actions are only undoable/redoable for a limited time. + /// For example, restoring a closed split can only be done for + /// some number of seconds since the split was closed. The exact + /// amount is configured with the `undo-timeout` configuration settings. + /// + /// The undo/redo actions being limited ensures that there is + /// bounded memory usage over time, closed surfaces don't continue running + /// in the background indefinitely, and the keybinds become available + /// for terminal applications to use. + /// + /// Only implemented on macOS. + undo, + + /// Redo the last undoable action for the focused surface or terminal, + /// if possible. See "undo" for more details on what can and cannot + /// be undone or redone. + redo, + + /// End the currently active key sequence, if any, and flush the + /// keys up to this point to the terminal, excluding the key that + /// triggered this action. + /// + /// For example: `ctrl+w>escape=end_key_sequence` would encode + /// `ctrl+w` to the terminal and exit the key sequence. + /// + /// Normally, an invalid sequence will reset the key sequence and + /// flush all data including the invalid key. This action allows + /// you to flush only the prior keys, which is useful when you want + /// to bind something like a control key (`ctrl+w`) but not send + /// additional inputs. + end_key_sequence, + + /// Activate a named key table (see `keybind` configuration documentation). + /// The named key table will remain active until `deactivate_key_table` + /// is called. If you want a one-shot key table activation, use the + /// `activate_key_table_once` action instead. + /// + /// If the named key table does not exist, this action has no effect + /// and performable will report false. + /// + /// If the named key table is already the currently active key table, + /// this action has no effect and performable will report false. + activate_key_table: []const u8, + + /// Same as activate_key_table, but the key table will only be active + /// until the first valid keybinding from that table is used (including + /// any defined `catch_all` bindings). + /// + /// The "once" check is only done if this is the currently active + /// key table. If another key table is activated later, then this + /// table will remain active until it pops back out to being the + /// active key table. + activate_key_table_once: []const u8, + + /// Deactivate the currently active key table, if any. The next most + /// recently activated key table (if any) will become active again. + /// If no key table is active, this action has no effect. + deactivate_key_table, + + /// Deactivate all active key tables. If no active key table exists, + /// this will report performable as false. + deactivate_all_key_tables, + + /// Quit Ghostty. + quit, + + /// Crash Ghostty in the desired thread for the focused surface. + /// + /// WARNING: This is a hard crash (panic) and data can be lost. + /// + /// The purpose of this action is to test crash handling. For some + /// users, it may be useful to test crash reporting functionality in + /// order to determine if it all works as expected. + /// + /// The value determines the crash location: + /// + /// - `main` + /// + /// Crash on the main (GUI) thread. + /// + /// - `io` + /// + /// Crash on the IO thread for the focused surface. + /// + /// - `render` + /// + /// Crash on the render thread for the focused surface. + /// + crash: CrashThread, + + pub const Key = @typeInfo(Action).@"union".tag_type.?; + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk => @import("gobject").ext.defineBoxed( + Action, + .{ .name = "GhosttyBindingAction" }, + ), + + .none => void, + }; + + pub const CrashThread = enum { + main, + io, + render, + }; + + pub const CursorKey = struct { + normal: []const u8, + application: []const u8, + + pub fn clone( + self: CursorKey, + alloc: Allocator, + ) Allocator.Error!CursorKey { + return .{ + .normal = try alloc.dupe(u8, self.normal), + .application = try alloc.dupe(u8, self.application), + }; + } + + pub fn format( + self: CursorKey, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + _ = self; + _ = writer; + @panic("formatting not supported"); + } + }; + + pub const NavigateSearch = enum { + previous, + next, + }; + + pub const AdjustSelection = enum { + left, + right, + up, + down, + page_up, + page_down, + home, + end, + beginning_of_line, + end_of_line, + }; + + pub const SplitDirection = enum { + right, + down, + left, + up, + auto, // splits along the larger direction + + pub const default: SplitDirection = .auto; + }; + + pub const SplitFocusDirection = enum { + previous, + next, + up, + left, + down, + right, + + pub fn parse(input: []const u8) !SplitFocusDirection { + return std.meta.stringToEnum(SplitFocusDirection, input) orelse { + // For backwards compatibility we map "top" and "bottom" onto the enum + // values "up" and "down" + if (std.mem.eql(u8, input, "top")) { + return .up; + } else if (std.mem.eql(u8, input, "bottom")) { + return .down; + } else { + return Error.InvalidFormat; + } + }; + } + + test "parse" { + const testing = std.testing; + + try testing.expectEqual(.previous, try SplitFocusDirection.parse("previous")); + try testing.expectEqual(.next, try SplitFocusDirection.parse("next")); + + try testing.expectEqual(.up, try SplitFocusDirection.parse("up")); + try testing.expectEqual(.left, try SplitFocusDirection.parse("left")); + try testing.expectEqual(.down, try SplitFocusDirection.parse("down")); + try testing.expectEqual(.right, try SplitFocusDirection.parse("right")); + + try testing.expectEqual(.up, try SplitFocusDirection.parse("top")); + try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom")); + + try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("")); + try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("green")); + } + }; + + pub const SplitResizeDirection = enum { + up, + down, + left, + right, + }; + + pub const GotoWindow = enum { + previous, + next, + }; + + pub const SplitResizeParameter = struct { + SplitResizeDirection, + u16, + }; + + pub const CopyToClipboard = enum { + plain, + vt, + html, + + /// This type will mix multiple distinct types with a set content-type + /// such as text/html for html, so that the OS/application can choose + /// what is best when pasting. + mixed, + + pub const default: CopyToClipboard = .mixed; + }; + + pub const WriteScreen = struct { + action: WriteScreen.Action, + emit: WriteScreen.Format, + + pub const copy: WriteScreen = .{ .action = .copy, .emit = .plain }; + pub const paste: WriteScreen = .{ .action = .paste, .emit = .plain }; + pub const open: WriteScreen = .{ .action = .open, .emit = .plain }; + + pub const Action = enum { + copy, + paste, + open, + }; + + pub const Format = enum { + plain, + vt, + html, + }; + + pub fn parse(param: []const u8) !WriteScreen { + // If we don't have a `,`, default to the plain format. This is + // also very important for backwards compatibility before Ghostty + // 1.3 which didn't support output formats. + const idx = std.mem.indexOfScalar(u8, param, ',') orelse return .{ + .action = try Binding.Action.parseEnum( + WriteScreen.Action, + param, + ), + .emit = .plain, + }; + + return .{ + .action = try Binding.Action.parseEnum( + WriteScreen.Action, + param[0..idx], + ), + .emit = try Binding.Action.parseEnum( + WriteScreen.Format, + param[idx + 1 ..], + ), + }; + } + + pub fn clone( + self: WriteScreen, + alloc: Allocator, + ) Allocator.Error!WriteScreen { + _ = alloc; + return self; + } + + pub fn format(self: WriteScreen, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("{t},{t}", .{ + self.action, + self.emit, + }); + } + }; + + // Extern because it is used in the embedded runtime ABI. + pub const InspectorMode = enum { + toggle, + show, + hide, + }; + + pub const CloseTabMode = enum { + this, + other, + right, + + pub const default: CloseTabMode = .this; + }; + + fn parseEnum(comptime T: type, value: []const u8) !T { + return std.meta.stringToEnum(T, value) orelse return Error.InvalidFormat; + } + + fn parseInt(comptime T: type, value: []const u8) !T { + return std.fmt.parseInt(T, value, 10) catch return Error.InvalidFormat; + } + + fn parseFloat(comptime T: type, value: []const u8) !T { + return std.fmt.parseFloat(T, value) catch return Error.InvalidFormat; + } + + fn parseParameter( + comptime field: std.builtin.Type.UnionField, + param: []const u8, + ) !field.type { + const field_info = @typeInfo(field.type); + + // Fields can provide a custom "parse" function + if (field_info == .@"struct" or + field_info == .@"union" or + field_info == .@"enum") + { + if (@hasDecl(field.type, "parse") and + @typeInfo(@TypeOf(field.type.parse)) == .@"fn") + { + return try field.type.parse(param); + } + } + + return switch (field_info) { + .@"enum" => try parseEnum(field.type, param), + .int => try parseInt(field.type, param), + .float => try parseFloat(field.type, param), + .@"struct" => |info| blk: { + // Only tuples are supported to avoid ambiguity with field + // ordering + comptime assert(info.is_tuple); + + var it = std.mem.splitAny(u8, param, ","); + var value: field.type = undefined; + inline for (info.fields) |field_| { + const next = it.next() orelse return Error.InvalidFormat; + @field(value, field_.name) = switch (@typeInfo(field_.type)) { + .@"enum" => try parseEnum(field_.type, next), + .int => try parseInt(field_.type, next), + .float => try parseFloat(field_.type, next), + else => unreachable, + }; + } + + // If we have extra parameters it is an error + if (it.next() != null) return Error.InvalidFormat; + + break :blk value; + }, + + else => unreachable, + }; + } + + /// Parse an action in the format of "key=value" where key is the + /// action name and value is the action parameter. The parameter + /// is optional depending on the action. + pub fn parse(input: []const u8) !Action { + // Split our action by colon. A colon may not exist for some + // actions so it is optional. The part preceding the colon is the + // action name. + const colonIdx = std.mem.indexOf(u8, input, ":"); + const action = input[0..(colonIdx orelse input.len)]; + + // An action name is always required + if (action.len == 0) return Error.InvalidFormat; + + const actionInfo = @typeInfo(Action).@"union"; + inline for (actionInfo.fields) |field| { + if (std.mem.eql(u8, action, field.name)) { + // If the field type is void we expect no value + switch (field.type) { + void => { + if (colonIdx != null) return Error.InvalidFormat; + return @unionInit(Action, field.name, {}); + }, + + []const u8 => { + const idx = colonIdx orelse return Error.InvalidFormat; + const param = input[idx + 1 ..]; + return @unionInit(Action, field.name, param); + }, + + // Cursor keys can't be set currently + Action.CursorKey => return Error.InvalidAction, + + else => { + // Get the parameter after the colon. The parameter + // can be optional for action types that can have a + // "default" decl. + const idx = colonIdx orelse { + switch (@typeInfo(field.type)) { + .@"struct", + .@"union", + .@"enum", + => if (@hasDecl(field.type, "default")) { + return @unionInit( + Action, + field.name, + @field(field.type, "default"), + ); + }, + + else => {}, + } + + return Error.InvalidFormat; + }; + + const param = input[idx + 1 ..]; + return @unionInit( + Action, + field.name, + try parseParameter(field, param), + ); + }, + } + } + } + + return Error.InvalidAction; + } + + /// The scope of an action. The scope is the context in which an action + /// must be executed. + pub const Scope = enum { + app, + surface, + }; + + /// Returns the scope of an action. + pub fn scope(self: Action) Scope { + return switch (self) { + // Doesn't really matter, so we'll see app. + .ignore, + .unbind, + => .app, + + // Obviously app actions. + .open_config, + .reload_config, + .close_all_windows, + .quit, + .toggle_quick_terminal, + .toggle_visibility, + .check_for_updates, + .show_gtk_inspector, + => .app, + + // These are app but can be special-cased in a surface context. + .new_window, + .undo, + .redo, + => .app, + + // Obviously surface actions. + .csi, + .esc, + .text, + .cursor_key, + .search, + .navigate_search, + .search_selection, + .start_search, + .end_search, + .reset, + .copy_to_clipboard, + .copy_url_to_clipboard, + .copy_title_to_clipboard, + .paste_from_clipboard, + .paste_from_selection, + .increase_font_size, + .decrease_font_size, + .reset_font_size, + .set_font_size, + .prompt_surface_title, + .prompt_tab_title, + .set_surface_title, + .set_tab_title, + .clear_screen, + .select_all, + .scroll_to_top, + .scroll_to_bottom, + .scroll_to_selection, + .scroll_to_row, + .scroll_page_up, + .scroll_page_down, + .scroll_page_fractional, + .scroll_page_lines, + .adjust_selection, + .jump_to_prompt, + .write_scrollback_file, + .write_screen_file, + .write_selection_file, + .close_surface, + .close_tab, + .close_window, + .toggle_maximize, + .toggle_fullscreen, + .toggle_window_decorations, + .toggle_window_float_on_top, + .toggle_secure_input, + .toggle_mouse_reporting, + .toggle_command_palette, + .toggle_background_opacity, + .show_on_screen_keyboard, + .reset_window_size, + .activate_key_table, + .activate_key_table_once, + .deactivate_key_table, + .deactivate_all_key_tables, + .end_key_sequence, + .crash, + => .surface, + + // These are less obvious surface actions. They're surface + // actions because they are relevant to the surface they + // come from. For example `new_window` needs to be sourced to + // a surface so inheritance can be done correctly. + .new_tab, + .previous_tab, + .next_tab, + .last_tab, + .goto_tab, + .move_tab, + .toggle_tab_overview, + .new_split, + .goto_split, + .goto_window, + .toggle_split_zoom, + .toggle_readonly, + .resize_split, + .equalize_splits, + .inspector, + => .surface, + }; + } + + /// Returns a union type that only contains actions that are scoped to + /// the given scope. + pub fn Scoped(comptime s: Scope) type { + @setEvalBranchQuota(100_000); + + const all_fields = @typeInfo(Action).@"union".fields; + + // Find all fields that are app-scoped + var i: usize = 0; + var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined; + var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined; + for (all_fields) |field| { + const action = @unionInit(Action, field.name, undefined); + if (action.scope() == s) { + union_fields[i] = field; + enum_fields[i] = .{ .name = field.name, .value = i }; + i += 1; + } + } + + // Build our union + return @Type(.{ .@"union" = .{ + .layout = .auto, + .tag_type = @Type(.{ .@"enum" = .{ + .tag_type = std.math.IntFittingRange(0, i), + .fields = enum_fields[0..i], + .decls = &.{}, + .is_exhaustive = true, + } }), + .fields = union_fields[0..i], + .decls = &.{}, + } }); + } + + /// Returns the scoped version of this action. If the action is not + /// scoped to the given scope then this returns null. + /// + /// The benefit of this function is that it allows us to use Zig's + /// exhaustive switch safety to ensure we always properly handle certain + /// scoped actions. + pub fn scoped(self: Action, comptime s: Scope) ?Scoped(s) { + switch (self) { + inline else => |v, tag| { + // Use comptime to prune out non-app actions + if (comptime @unionInit( + Action, + @tagName(tag), + undefined, + ).scope() != s) return null; + + // Initialize our app action + return @unionInit( + Scoped(s), + @tagName(tag), + v, + ); + }, + } + } + + /// Implements the formatter for the fmt package. This encodes the + /// action back into the format used by parse. + pub fn format( + self: Action, + writer: *std.Io.Writer, + ) !void { + switch (self) { + inline else => |value| { + // All actions start with the tag. + try writer.print("{s}", .{@tagName(self)}); + + // Only write the value depending on the type if it's not void + if (@TypeOf(value) != void) { + try writer.writeAll(":"); + try formatValue(writer, value); + } + }, + } + } + + fn formatValue( + writer: *std.Io.Writer, + value: anytype, + ) !void { + const Value = @TypeOf(value); + const value_info = @typeInfo(Value); + switch (Value) { + void => {}, + []const u8 => try std.zig.stringEscape(value, writer), + else => switch (value_info) { + .@"enum" => try writer.print("{t}", .{value}), + .float => try writer.print("{d}", .{value}), + .int => try writer.print("{d}", .{value}), + .@"struct" => |info| format: { + if (@hasDecl(Value, "format")) { + try value.format(writer); + break :format; + } + + if (!info.is_tuple) { + @compileError("unhandled struct type: " ++ @typeName(Value)); + } else { + inline for (info.fields, 0..) |field, i| { + try formatValue(writer, @field(value, field.name)); + if (i + 1 < info.fields.len) try writer.writeAll(","); + } + } + }, + else => @compileError("unhandled type: " ++ @typeName(Value)), + }, + } + } + + /// Clone this action with the given allocator. The allocator + /// should be an arena-style allocator since fine-grained + /// deallocation is not possible. + pub fn clone(self: Action, alloc: Allocator) Allocator.Error!Action { + return switch (self) { + inline else => |value, tag| @unionInit( + Action, + @tagName(tag), + try cloneValue(alloc, value), + ), + }; + } + + fn cloneValue( + alloc: Allocator, + value: anytype, + ) Allocator.Error!@TypeOf(value) { + return switch (@typeInfo(@TypeOf(value))) { + .void, + .int, + .float, + .@"enum", + => value, + + .pointer => |info| slice: { + comptime assert(info.size == .slice); + break :slice try alloc.dupe( + info.child, + value, + ); + }, + + .@"struct" => |info| if (info.is_tuple) + value + else + try value.clone(alloc), + + else => { + @compileLog(@TypeOf(value)); + @compileError("unexpected type"); + }, + }; + } + + /// Returns a hash code that can be used to uniquely identify this + /// action. + pub fn hash(self: Action) u64 { + var hasher = std.hash.Wyhash.init(0); + self.hashIncremental(&hasher); + return hasher.final(); + } + + /// Hash the action into the given hasher. + fn hashIncremental(self: Action, hasher: anytype) void { + // Always has the active tag. + const Tag = @typeInfo(Action).@"union".tag_type.?; + std.hash.autoHash(hasher, @as(Tag, self)); + + // Hash the value of the field. + switch (self) { + inline else => |field| { + const FieldType = @TypeOf(field); + switch (FieldType) { + // Do nothing for void + void => {}, + + // Floats are hashed by their bits. This is totally not + // portable and there are edge cases such as NaNs and + // signed zeros but these are not cases we expect for + // our bindings. + f32 => std.hash.autoHash( + hasher, + @as(u32, @bitCast(field)), + ), + f64 => std.hash.autoHash( + hasher, + @as(u64, @bitCast(field)), + ), + + // Everything else automatically handle. + else => std.hash.autoHashStrat( + hasher, + field, + .DeepRecursive, + ), + } + }, + } + } + + /// Compares two actions for equality. + pub fn equal(self: Action, other: Action) bool { + if (std.meta.activeTag(self) != std.meta.activeTag(other)) return false; + return switch (self) { + inline else => |field_self, tag| { + const field_other = @field(other, @tagName(tag)); + return deepEqual( + @TypeOf(field_self), + field_self, + field_other, + ); + }, + }; + } + + /// For the Set.Context + const bindingSetEqual = equal; +}; + +/// Trigger is the associated key state that can trigger an action. +/// This is an extern struct because this is also used in the C API. +/// +/// This must be kept in sync with include/ghostty.h ghostty_input_trigger_s +pub const Trigger = struct { + /// The key that has to be pressed for a binding to take action. + key: Trigger.Key = .{ .physical = .unidentified }, + + /// The key modifiers that must be active for this to match. + mods: key.Mods = .{}, + + pub const Key = union(C.Tag) { + /// key is the "physical" version. This is the same as mapped for + /// standard US keyboard layouts. For non-US keyboard layouts, this + /// is used to bind to a physical key location rather than a translated + /// key. + physical: key.Key, + + /// This is used for binding to keys that produce a certain unicode + /// codepoint. This is useful for binding to keys that don't have a + /// registered keycode with Ghostty. + unicode: u21, + + /// A catch-all key that matches any key press that is otherwise + /// unbound. + catch_all, + }; + + /// The extern struct used for triggers in the C API. + pub const C = extern struct { + tag: Tag = .physical, + key: C.Key = .{ .physical = .unidentified }, + mods: key.Mods = .{}, + + pub const Tag = enum(c_int) { + physical, + unicode, + catch_all, + }; + + pub const Key = extern union { + physical: key.Key, + unicode: u32, + }; + }; + + /// Parse a single trigger. The input is expected to be ONLY the trigger + /// (i.e. in the sequence `a=ignore` input is only `a`). The trigger may + /// not be part of a sequence (i.e. `a>b`). This parses exactly a single + /// trigger. + pub fn parse(input: []const u8) !Trigger { + if (input.len == 0) return Error.InvalidFormat; + var result: Trigger = .{}; + var rem: []const u8 = input; + loop: while (rem.len > 0) { + const idx = std.mem.indexOfScalar(u8, rem, '+') orelse rem.len; + const part = rem[0..idx]; + rem = if (idx >= rem.len) "" else rem[idx + 1 ..]; + + // Check if its a modifier + const modsInfo = @typeInfo(key.Mods).@"struct"; + inline for (modsInfo.fields) |field| { + if (field.type == bool) { + if (std.mem.eql(u8, part, field.name)) { + // Repeat not allowed + if (@field(result.mods, field.name)) return Error.InvalidFormat; + @field(result.mods, field.name) = true; + continue :loop; + } + } + } + + // Alias modifiers + inline for (key_mods.alias) |pair| { + if (std.mem.eql(u8, part, pair[0])) { + // Repeat not allowed + const field = @tagName(pair[1]); + if (@field(result.mods, field)) return Error.InvalidFormat; + @field(result.mods, field) = true; + continue :loop; + } + } + + // Anything after this point is a key and we only support + // single keys. + if (!result.isKeyUnset()) return Error.InvalidFormat; + + // If the part is empty it means that it is actually + // a literal `+`, which we treat as a Unicode character. + if (part.len == 0) { + result.key = .{ .unicode = '+' }; + continue :loop; + } + + // Check if its a key + const keysInfo = @typeInfo(key.Key).@"enum"; + inline for (keysInfo.fields) |field| { + if (!std.mem.eql(u8, field.name, "unidentified")) { + if (std.mem.eql(u8, part, field.name)) { + const keyval = @field(key.Key, field.name); + result.key = .{ .physical = keyval }; + continue :loop; + } + } + } + + // If we're still unset and we have exactly one unicode + // character then we can use that as a key. + if (result.isKeyUnset()) unicode: { + // Invalid UTF8 drops to invalid format + const view = std.unicode.Utf8View.init(part) catch break :unicode; + var it = view.iterator(); + + // No codepoints or multiple codepoints drops to invalid format + const cp = it.nextCodepoint() orelse break :unicode; + if (it.nextCodepoint() != null) break :unicode; + + result.key = .{ .unicode = cp }; + continue :loop; + } + + // Look for a matching w3c name next. + if (key.Key.fromW3C(part)) |w3c_key| { + result.key = .{ .physical = w3c_key }; + continue :loop; + } + + // Check for catch_all. We do this near the end since its unlikely + // in most cases that we're setting a catch-all key. + if (std.mem.eql(u8, part, "catch_all")) { + result.key = .catch_all; + continue :loop; + } + + // If we're still unset then we look for backwards compatible + // keys with Ghostty 1.1.x. We do this last so its least likely + // to impact performance for modern users. + if (backwards_compatible_keys.get(part)) |old_key| { + result.key = old_key; + continue :loop; + } + + // We didn't recognize this value + return Error.InvalidFormat; + } + + return result; + } + + /// The values that are backwards compatible with Ghostty 1.1.x. + /// Ghostty 1.2+ doesn't support these anymore since we moved to + /// W3C key codes. + const backwards_compatible_keys = std.StaticStringMap(Key).initComptime(.{ + .{ "zero", Key{ .unicode = '0' } }, + .{ "one", Key{ .unicode = '1' } }, + .{ "two", Key{ .unicode = '2' } }, + .{ "three", Key{ .unicode = '3' } }, + .{ "four", Key{ .unicode = '4' } }, + .{ "five", Key{ .unicode = '5' } }, + .{ "six", Key{ .unicode = '6' } }, + .{ "seven", Key{ .unicode = '7' } }, + .{ "eight", Key{ .unicode = '8' } }, + .{ "nine", Key{ .unicode = '9' } }, + .{ "plus", Key{ .unicode = '+' } }, + .{ "apostrophe", Key{ .unicode = '\'' } }, + .{ "grave_accent", Key{ .physical = .backquote } }, + .{ "left_bracket", Key{ .physical = .bracket_left } }, + .{ "right_bracket", Key{ .physical = .bracket_right } }, + .{ "up", Key{ .physical = .arrow_up } }, + .{ "down", Key{ .physical = .arrow_down } }, + .{ "left", Key{ .physical = .arrow_left } }, + .{ "right", Key{ .physical = .arrow_right } }, + .{ "kp_0", Key{ .physical = .numpad_0 } }, + .{ "kp_1", Key{ .physical = .numpad_1 } }, + .{ "kp_2", Key{ .physical = .numpad_2 } }, + .{ "kp_3", Key{ .physical = .numpad_3 } }, + .{ "kp_4", Key{ .physical = .numpad_4 } }, + .{ "kp_5", Key{ .physical = .numpad_5 } }, + .{ "kp_6", Key{ .physical = .numpad_6 } }, + .{ "kp_7", Key{ .physical = .numpad_7 } }, + .{ "kp_8", Key{ .physical = .numpad_8 } }, + .{ "kp_9", Key{ .physical = .numpad_9 } }, + .{ "kp_add", Key{ .physical = .numpad_add } }, + .{ "kp_subtract", Key{ .physical = .numpad_subtract } }, + .{ "kp_multiply", Key{ .physical = .numpad_multiply } }, + .{ "kp_divide", Key{ .physical = .numpad_divide } }, + .{ "kp_decimal", Key{ .physical = .numpad_decimal } }, + .{ "kp_enter", Key{ .physical = .numpad_enter } }, + .{ "kp_equal", Key{ .physical = .numpad_equal } }, + .{ "kp_separator", Key{ .physical = .numpad_separator } }, + .{ "kp_left", Key{ .physical = .numpad_left } }, + .{ "kp_right", Key{ .physical = .numpad_right } }, + .{ "kp_up", Key{ .physical = .numpad_up } }, + .{ "kp_down", Key{ .physical = .numpad_down } }, + .{ "kp_page_up", Key{ .physical = .numpad_page_up } }, + .{ "kp_page_down", Key{ .physical = .numpad_page_down } }, + .{ "kp_home", Key{ .physical = .numpad_home } }, + .{ "kp_end", Key{ .physical = .numpad_end } }, + .{ "kp_insert", Key{ .physical = .numpad_insert } }, + .{ "kp_delete", Key{ .physical = .numpad_delete } }, + .{ "kp_begin", Key{ .physical = .numpad_begin } }, + .{ "left_shift", Key{ .physical = .shift_left } }, + .{ "right_shift", Key{ .physical = .shift_right } }, + .{ "left_control", Key{ .physical = .control_left } }, + .{ "right_control", Key{ .physical = .control_right } }, + .{ "left_alt", Key{ .physical = .alt_left } }, + .{ "right_alt", Key{ .physical = .alt_right } }, + .{ "left_super", Key{ .physical = .meta_left } }, + .{ "right_super", Key{ .physical = .meta_right } }, + + // Physical variants. This is a blunt approach to this but its + // glue for backwards compatibility so I'm not too worried about + // making this super nice. + .{ "physical:zero", Key{ .physical = .digit_0 } }, + .{ "physical:one", Key{ .physical = .digit_1 } }, + .{ "physical:two", Key{ .physical = .digit_2 } }, + .{ "physical:three", Key{ .physical = .digit_3 } }, + .{ "physical:four", Key{ .physical = .digit_4 } }, + .{ "physical:five", Key{ .physical = .digit_5 } }, + .{ "physical:six", Key{ .physical = .digit_6 } }, + .{ "physical:seven", Key{ .physical = .digit_7 } }, + .{ "physical:eight", Key{ .physical = .digit_8 } }, + .{ "physical:nine", Key{ .physical = .digit_9 } }, + .{ "physical:apostrophe", Key{ .physical = .quote } }, + .{ "physical:grave_accent", Key{ .physical = .backquote } }, + .{ "physical:left_bracket", Key{ .physical = .bracket_left } }, + .{ "physical:right_bracket", Key{ .physical = .bracket_right } }, + .{ "physical:up", Key{ .physical = .arrow_up } }, + .{ "physical:down", Key{ .physical = .arrow_down } }, + .{ "physical:left", Key{ .physical = .arrow_left } }, + .{ "physical:right", Key{ .physical = .arrow_right } }, + .{ "physical:kp_0", Key{ .physical = .numpad_0 } }, + .{ "physical:kp_1", Key{ .physical = .numpad_1 } }, + .{ "physical:kp_2", Key{ .physical = .numpad_2 } }, + .{ "physical:kp_3", Key{ .physical = .numpad_3 } }, + .{ "physical:kp_4", Key{ .physical = .numpad_4 } }, + .{ "physical:kp_5", Key{ .physical = .numpad_5 } }, + .{ "physical:kp_6", Key{ .physical = .numpad_6 } }, + .{ "physical:kp_7", Key{ .physical = .numpad_7 } }, + .{ "physical:kp_8", Key{ .physical = .numpad_8 } }, + .{ "physical:kp_9", Key{ .physical = .numpad_9 } }, + .{ "physical:kp_add", Key{ .physical = .numpad_add } }, + .{ "physical:kp_subtract", Key{ .physical = .numpad_subtract } }, + .{ "physical:kp_multiply", Key{ .physical = .numpad_multiply } }, + .{ "physical:kp_divide", Key{ .physical = .numpad_divide } }, + .{ "physical:kp_decimal", Key{ .physical = .numpad_decimal } }, + .{ "physical:kp_enter", Key{ .physical = .numpad_enter } }, + .{ "physical:kp_equal", Key{ .physical = .numpad_equal } }, + .{ "physical:kp_separator", Key{ .physical = .numpad_separator } }, + .{ "physical:kp_left", Key{ .physical = .numpad_left } }, + .{ "physical:kp_right", Key{ .physical = .numpad_right } }, + .{ "physical:kp_up", Key{ .physical = .numpad_up } }, + .{ "physical:kp_down", Key{ .physical = .numpad_down } }, + .{ "physical:kp_page_up", Key{ .physical = .numpad_page_up } }, + .{ "physical:kp_page_down", Key{ .physical = .numpad_page_down } }, + .{ "physical:kp_home", Key{ .physical = .numpad_home } }, + .{ "physical:kp_end", Key{ .physical = .numpad_end } }, + .{ "physical:kp_insert", Key{ .physical = .numpad_insert } }, + .{ "physical:kp_delete", Key{ .physical = .numpad_delete } }, + .{ "physical:kp_begin", Key{ .physical = .numpad_begin } }, + .{ "physical:left_shift", Key{ .physical = .shift_left } }, + .{ "physical:right_shift", Key{ .physical = .shift_right } }, + .{ "physical:left_control", Key{ .physical = .control_left } }, + .{ "physical:right_control", Key{ .physical = .control_right } }, + .{ "physical:left_alt", Key{ .physical = .alt_left } }, + .{ "physical:right_alt", Key{ .physical = .alt_right } }, + .{ "physical:left_super", Key{ .physical = .meta_left } }, + .{ "physical:right_super", Key{ .physical = .meta_right } }, + }); + + /// Returns true if this trigger has no key set. + pub fn isKeyUnset(self: Trigger) bool { + return switch (self.key) { + .physical => |v| v == .unidentified, + .unicode, .catch_all => false, + }; + } + + /// Returns a hash code that can be used to uniquely identify this trigger. + pub fn hash(self: Trigger) u64 { + var hasher = std.hash.Wyhash.init(0); + self.hashIncremental(&hasher); + return hasher.final(); + } + + /// Hash the trigger into the given hasher. + fn hashIncremental(self: Trigger, hasher: anytype) void { + std.hash.autoHash(hasher, std.meta.activeTag(self.key)); + switch (self.key) { + .physical => |v| std.hash.autoHash(hasher, v), + .unicode => |cp| std.hash.autoHash( + hasher, + foldedCodepoint(cp), + ), + .catch_all => {}, + } + std.hash.autoHash(hasher, self.mods.binding()); + } + + /// The codepoint we use for comparisons. Case folding can result + /// in more codepoints so we need to use a 3 element array. + fn foldedCodepoint(cp: u21) [3]u21 { + // ASCII fast path + if (uucode.ascii.isAlphabetic(cp)) { + return .{ uucode.ascii.toLower(cp), 0, 0 }; + } + + // Unicode slow path. Case folding can result in more codepoints. + // If more codepoints are produced then we return the codepoint + // as-is which isn't correct but until we have a failing test + // then I don't want to handle this. + var buffer: [1]u21 = undefined; + const slice = uucode.get(.case_folding_full, cp).with(&buffer, cp); + var array: [3]u21 = [_]u21{0} ** 3; + @memcpy(array[0..slice.len], slice); + return array; + } + + /// Returns true if two triggers are equal. + pub fn equal(self: Trigger, other: Trigger) bool { + if (self.mods != other.mods) return false; + const self_tag = std.meta.activeTag(self.key); + const other_tag = std.meta.activeTag(other.key); + if (self_tag != other_tag) return false; + return switch (self.key) { + .physical => |v| v == other.key.physical, + .unicode => |v| v == other.key.unicode, + .catch_all => true, + }; + } + + /// Returns true if two triggers are equal using folded codepoints. + pub fn foldedEqual(self: Trigger, other: Trigger) bool { + if (self.mods != other.mods) return false; + const self_tag = std.meta.activeTag(self.key); + const other_tag = std.meta.activeTag(other.key); + if (self_tag != other_tag) return false; + return switch (self.key) { + .physical => |v| v == other.key.physical, + .unicode => |v| deepEqual( + [3]u21, + foldedCodepoint(v), + foldedCodepoint(other.key.unicode), + ), + .catch_all => true, + }; + } + + /// For the Set.Context + const bindingSetEqual = foldedEqual; + + /// Convert the trigger to a C API compatible trigger. + pub fn cval(self: Trigger) C { + return .{ + .tag = self.key, + .key = switch (self.key) { + .physical => |v| .{ .physical = v }, + .unicode => |v| .{ .unicode = @intCast(v) }, + // catch_all has no associated value so its an error + // for a C consumer to look at it. + .catch_all => undefined, + }, + .mods = self.mods, + }; + } + + /// Format implementation for fmt package. + pub fn format( + self: Trigger, + writer: *std.Io.Writer, + ) !void { + // Modifiers first + if (self.mods.super) try writer.writeAll("super+"); + if (self.mods.ctrl) try writer.writeAll("ctrl+"); + if (self.mods.alt) try writer.writeAll("alt+"); + if (self.mods.shift) try writer.writeAll("shift+"); + + // Key + switch (self.key) { + .physical => |k| try writer.print("{t}", .{k}), + .unicode => |c| try writer.print("{u}", .{c}), + .catch_all => try writer.writeAll("catch_all"), + } + } +}; + +/// A structure that contains a set of bindings and focuses on fast lookup. +/// The use case is that this will be called on EVERY key input to look +/// for an associated action so it must be fast. +pub const Set = struct { + const HashMap = std.ArrayHashMapUnmanaged( + Trigger, + Value, + Context(Trigger), + true, + ); + + const ReverseMap = std.ArrayHashMapUnmanaged( + Action, + Trigger, + Context(Action), + true, + ); + + /// The set of bindings. + bindings: HashMap = .{}, + + /// The reverse mapping of action to binding. Note that multiple + /// bindings can map to the same action and this map will only have + /// the most recently added binding for an action. + /// + /// Sequenced triggers are never present in the reverse map at this time. + /// This is a conscious decision since the primary use case of the reverse + /// map is to support GUI toolkit keyboard accelerators and no mainstream + /// GUI toolkit supports sequences. + /// + /// Performable triggers are also not present in the reverse map. This + /// is so that GUI toolkits don't register performable triggers as + /// menu shortcuts (the primary use case of the reverse map). GUI toolkits + /// such as GTK handle menu shortcuts too early in the event lifecycle + /// for performable to work so this is a conscious decision to ease the + /// integration with GUI toolkits. + reverse: ReverseMap = .{}, + + /// The chain parent is the information necessary to attach a chained + /// action to the proper location in our mapping. It tracks both the + /// entry in the hashmap and the set it belongs to, which is needed + /// to properly update reverse mappings when converting a leaf to + /// a chained action. + chain_parent: ?ChainParent = null, + + /// Information about a chain parent entry, including which set it + /// belongs to. This is needed because reverse mappings are only + /// maintained in the root set, but the chain parent entry may be + /// in a nested set (for leader key sequences). + const ChainParent = struct { + key_ptr: *Trigger, + value_ptr: *Value, + set: *Set, + }; + + /// The entry type for the forward mapping of trigger to action. + pub const Value = union(enum) { + /// This key is a leader key in a sequence. You must follow the given + /// set to find the next key in the sequence. + leader: *Set, + + /// This trigger completes a sequence and the value is the action + /// to take along with the flags that may define binding behavior. + leaf: Leaf, + + /// A set of actions to take in response to a trigger. + leaf_chained: LeafChained, + + /// Implements the formatter for the fmt package. This encodes the + /// action back into the format used by parse. + pub fn format( + self: Value, + writer: *std.Io.Writer, + ) !void { + switch (self) { + .leader => |set| { + // the leader key was already printed. + var iter = set.bindings.iterator(); + while (iter.next()) |binding| { + try writer.print( + ">{s}{s}", + .{ binding.key_ptr.*, binding.value_ptr.* }, + ); + } + }, + + .leaf => |leaf| { + // action implements the format + try writer.print("={s}", .{leaf.action}); + }, + } + } + + /// Writes the configuration entries for the binding + /// that this value is part of. + /// + /// The value may be part of multiple configuration entries + /// if they're all part of the same prefix sequence (e.g. 'a>b', 'a>c'). + /// These will result in multiple separate entries in the configuration. + /// + /// `buffer_stream` is a FixedBufferStream used for temporary storage + /// that is shared between calls to nested levels of the set. + /// For example, 'a>b>c=x' and 'a>b>d=y' will reuse the 'a>b' written + /// to the buffer before flushing it to the formatter with 'c=x' and 'd=y'. + pub fn formatEntries( + self: Value, + buffer: *std.Io.Writer, + formatter: EntryFormatter, + ) !void { + switch (self) { + .leader => |set| { + // We'll rewind to this position after each sub-entry, + // sharing the prefix between siblings. + const pos = buffer.end; + + var iter = set.bindings.iterator(); + while (iter.next()) |binding| { + // I'm not exactly if this is safe for any arbitrary + // writer since the Writer interface does not have any + // rewind functions, but for our use case of a + // fixed-size buffer writer this should work just fine. + buffer.end = pos; + buffer.print(">{f}", .{binding.key_ptr.*}) catch return error.OutOfMemory; + try binding.value_ptr.*.formatEntries(buffer, formatter); + } + }, + + .leaf => |leaf| { + // When we get to the leaf, the buffer_stream contains + // the full sequence of keys needed to reach this action. + buffer.print("={f}", .{leaf.action}) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); + }, + + .leaf_chained => |leaf| { + const pos = buffer.end; + for (leaf.actions.items, 0..) |action, i| { + if (i == 0) { + buffer.print("={f}", .{action}) catch return error.OutOfMemory; + } else { + buffer.end = 0; + buffer.print("chain={f}", .{action}) catch return error.OutOfMemory; + } + try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); + buffer.end = pos; + } + }, + } + } + }; + + /// Leaf node of a set is an action to trigger. This is a "leaf" compared + /// to the inner nodes which are "leaders" for sequences. + pub const Leaf = struct { + action: Action, + flags: Flags, + + pub fn clone( + self: Leaf, + alloc: Allocator, + ) Allocator.Error!Leaf { + return .{ + .action = try self.action.clone(alloc), + .flags = self.flags, + }; + } + + pub fn hash(self: Leaf) u64 { + var hasher = std.hash.Wyhash.init(0); + self.action.hash(&hasher); + std.hash.autoHash(&hasher, self.flags); + return hasher.final(); + } + + pub fn generic(self: *const Leaf) GenericLeaf { + return .{ + .flags = self.flags, + .actions = .{ .single = .{self.action} }, + }; + } + }; + + /// Leaf node of a set that triggers multiple actions in sequence. + pub const LeafChained = struct { + actions: std.ArrayList(Action), + flags: Flags, + + pub fn clone( + self: LeafChained, + alloc: Allocator, + ) Allocator.Error!LeafChained { + var cloned_actions = try self.actions.clone(alloc); + errdefer cloned_actions.deinit(alloc); + for (cloned_actions.items) |*action| { + action.* = try action.clone(alloc); + } + return .{ + .actions = cloned_actions, + .flags = self.flags, + }; + } + + pub fn deinit(self: *LeafChained, alloc: Allocator) void { + self.actions.deinit(alloc); + } + + pub fn generic(self: *const LeafChained) GenericLeaf { + return .{ + .flags = self.flags, + .actions = .{ .many = self.actions.items }, + }; + } + }; + + /// A generic leaf node that can be used to unify the handling of + /// leaf and leaf_chained. + pub const GenericLeaf = struct { + flags: Flags, + actions: union(enum) { + single: [1]Action, + many: []const Action, + }, + + pub fn actionsSlice(self: *const GenericLeaf) []const Action { + return switch (self.actions) { + .single => |*arr| arr, + .many => |slice| slice, + }; + } + }; + + /// A full key-value entry for the set. + pub const Entry = HashMap.Entry; + + pub fn deinit(self: *Set, alloc: Allocator) void { + // Clear any leaders if we have them + var it = self.bindings.iterator(); + while (it.next()) |entry| switch (entry.value_ptr.*) { + .leader => |s| { + s.deinit(alloc); + alloc.destroy(s); + }, + + .leaf_chained => |*l| l.deinit(alloc), + + .leaf => {}, + }; + + self.bindings.deinit(alloc); + self.reverse.deinit(alloc); + self.* = undefined; + } + + /// Parse a user input binding and add it to the set. This will handle + /// the "unbind" case, ensure consumed/unconsumed fields are set correctly, + /// handle sequences, etc. + /// + /// If this returns an OutOfMemory error then the set is in a broken + /// state and should not be used again. Any Error returned is validated + /// before any set modifications are made. + pub fn parseAndPut( + self: *Set, + alloc: Allocator, + input: []const u8, + ) (Allocator.Error || Error)!void { + // To make cleanup easier, we ensure that the full sequence is + // valid before making any set modifications. This is more expensive + // computationally but it makes cleanup way, way easier. + var it = try Parser.init(input); + while (try it.next()) |_| {} + it.reset(); + + // We use recursion so that we can utilize the stack as our state + // for cleanup. + const updated_set_ = self.parseAndPutRecurse( + self, + alloc, + &it, + ) catch |err| err: { + switch (err) { + // If this gets sent up to the root then we've unbound + // all the way up and this put was a success. + error.SequenceUnbind => break :err null, + + // If our parser input was too short then the format + // is invalid because we handle all valid cases. + error.UnexpectedEndOfInput => return error.InvalidFormat, + + // If we had a chain without a parent then the format is wrong. + error.NoChainParent => return error.InvalidFormat, + + // If we had an invalid action for a chain (e.g. unbind). + error.InvalidChainAction => return error.InvalidFormat, + + // Unrecoverable + error.OutOfMemory => return error.OutOfMemory, + } + + // Errors must never fall through. + unreachable; + }; + + // If we have an updated set (a binding was added) then we store + // it for our chain parent. If we didn't update a set then we clear + // our chain parent since chaining is no longer valid until a + // valid binding is saved. + if (updated_set_) |updated_set| { + // A successful addition must have recorded a chain parent. + assert(updated_set.chain_parent != null); + if (updated_set != self) self.chain_parent = updated_set.chain_parent; + assert(self.chain_parent != null); + } else { + self.chain_parent = null; + } + } + + const ParseAndPutRecurseError = Allocator.Error || error{ + SequenceUnbind, + NoChainParent, + UnexpectedEndOfInput, + InvalidChainAction, + }; + + /// Returns the set that was ultimately updated if a binding was + /// added. Unbind does not return a set since nothing was added. + fn parseAndPutRecurse( + root: *Set, + set: *Set, + alloc: Allocator, + it: *Parser, + ) ParseAndPutRecurseError!?*Set { + const elem = (it.next() catch unreachable) orelse + return error.UnexpectedEndOfInput; + switch (elem) { + .leader => |t| { + // If we have a leader, we need to upsert a set for it. + // Since we remove the value, we need to copy it. + const old: ?Value = if (set.get(t)) |entry| + entry.value_ptr.* + else + null; + if (old) |entry| switch (entry) { + // We have an existing leader for this key already + // so recurse into this set. + .leader => |s| return root.parseAndPutRecurse( + s, + alloc, + it, + ) catch |err| switch (err) { + // Our child put unbound. If our set is empty we + // need to dealloc and continue up. If our set is + // not empty then we're done. + error.SequenceUnbind => if (s.bindings.count() == 0) { + set.remove(alloc, t); + return error.SequenceUnbind; + } else null, + + error.NoChainParent, + error.UnexpectedEndOfInput, + error.InvalidChainAction, + error.OutOfMemory, + => err, + }, + + .leaf, .leaf_chained => { + // Remove the existing action. Fallthrough as if + // we don't have a leader. + set.remove(alloc, t); + }, + }; + + // Create our new set for this leader + const next = try alloc.create(Set); + errdefer alloc.destroy(next); + next.* = .{}; + errdefer next.deinit(alloc); + + // Insert the leader entry + try set.bindings.put(alloc, t, .{ .leader = next }); + + // Recurse + return root.parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { + // If our action was to unbind, we restore the old + // action if we have it. + error.SequenceUnbind => { + set.remove(alloc, t); + if (old) |entry| switch (entry) { + .leader => unreachable, // Handled above + .leaf => |leaf| set.putFlags( + alloc, + t, + leaf.action, + leaf.flags, + ) catch {}, + + .leaf_chained => |leaf| chain: { + // Rebuild our chain + set.putFlags( + alloc, + t, + leaf.actions.items[0], + leaf.flags, + ) catch break :chain; + for (leaf.actions.items[1..]) |action| { + set.appendChain( + alloc, + action, + ) catch { + set.remove(alloc, t); + break :chain; + }; + } + }, + }; + + return null; + }, + + error.NoChainParent, + error.UnexpectedEndOfInput, + error.InvalidChainAction, + error.OutOfMemory, + => return err, + }; + }, + + .binding => |b| switch (b.action) { + .unbind => { + set.remove(alloc, b.trigger); + return error.SequenceUnbind; + }, + + else => { + try set.putFlags( + alloc, + b.trigger, + b.action, + b.flags, + ); + return set; + }, + }, + + .chain => |action| { + // Chains can only happen on the root. + assert(set == root); + // Unbind is not valid for chains. + if (action == .unbind) return error.InvalidChainAction; + try set.appendChain(alloc, action); + return set; + }, + } + } + + /// Add a binding to the set. If the binding already exists then + /// this will overwrite it. + pub fn put( + self: *Set, + alloc: Allocator, + t: Trigger, + action: Action, + ) Allocator.Error!void { + try self.putFlags(alloc, t, action, .{}); + } + + /// Add a binding to the set with explicit flags. + pub fn putFlags( + self: *Set, + alloc: Allocator, + t: Trigger, + action: Action, + flags: Flags, + ) Allocator.Error!void { + // unbind should never go into the set, it should be handled prior + assert(action != .unbind); + + // This is true if we're going to track this entry as + // a reverse mapping. There are certain scenarios we don't. + // See the reverse map docs for more information. + const track_reverse: bool = !flags.performable; + + // No matter what our chained parent becomes invalid because + // getOrPut invalidates pointers. + self.chain_parent = null; + + const gop = try self.bindings.getOrPut(alloc, t); + self.chain_parent = .{ + .key_ptr = gop.key_ptr, + .value_ptr = gop.value_ptr, + .set = self, + }; + errdefer { + // If we have any errors we can't trust our values here. And + // we can't restore the old values because they're also invalidated + // by getOrPut so we just disable chaining. + self.chain_parent = null; + } + + if (gop.found_existing) switch (gop.value_ptr.*) { + // If we have a leader we need to clean up the memory + .leader => |s| { + s.deinit(alloc); + alloc.destroy(s); + }, + + // If we have an existing binding for this trigger, we have to + // update the reverse mapping to remove the old action. + .leaf => if (track_reverse) { + const t_hash = t.hash(); + for (0.., self.reverse.values()) |i, *value| { + if (t_hash == value.hash()) { + self.reverse.swapRemoveAt(i); + break; + } + } + }, + + // Chained leaves aren't in the reverse mapping so we just + // clear it out. + .leaf_chained => |*l| { + l.deinit(alloc); + }, + }; + + gop.value_ptr.* = .{ .leaf = .{ + .action = action, + .flags = flags, + } }; + errdefer _ = self.bindings.swapRemove(t); + + if (track_reverse) try self.reverse.put(alloc, action, t); + errdefer if (track_reverse) self.reverse.remove(action); + + // Invariant: after successful put, chain_parent must be valid and point + // to the entry we just added/updated. + assert(self.chain_parent != null); + assert(self.chain_parent.?.key_ptr == gop.key_ptr); + assert(self.chain_parent.?.value_ptr == gop.value_ptr); + assert(self.chain_parent.?.value_ptr.* == .leaf); + } + + /// Append a chained action to the prior set action. + /// + /// It is an error if there is no valid prior chain parent. + pub fn appendChain( + self: *Set, + alloc: Allocator, + action: Action, + ) (Allocator.Error || error{NoChainParent})!void { + // Unbind is not a valid chain action; callers must check this. + assert(action != .unbind); + + const parent = self.chain_parent orelse return error.NoChainParent; + switch (parent.value_ptr.*) { + // Leader can never be a chain parent. Verified through various + // assertions and unit tests. + .leader => unreachable, + + // If it is already a chained action, we just append the + // action. Easy! + .leaf_chained => |*leaf| try leaf.actions.append( + alloc, + action, + ), + + // If it is a leaf, we need to convert it to a leaf_chained. + // We also need to be careful to remove any prior reverse + // mappings for this action since chained actions are not + // part of the reverse mapping. + .leaf => |leaf| { + // Setup our failable actions list first. + var actions: std.ArrayList(Action) = .empty; + try actions.ensureTotalCapacity(alloc, 2); + errdefer actions.deinit(alloc); + actions.appendAssumeCapacity(leaf.action); + actions.appendAssumeCapacity(action); + + // Convert to leaf_chained first, before fixing up reverse + // mapping. This is important because fixupReverseForAction + // searches for other bindings with the same action, and we + // don't want to find this entry (which is now chained). + parent.value_ptr.* = .{ .leaf_chained = .{ + .actions = actions, + .flags = leaf.flags, + } }; + + // Clean up our reverse mapping. Chained actions are not + // part of the reverse mapping, so we need to fix up the + // reverse map (possibly restoring another trigger for the + // same action). + parent.set.fixupReverseForAction( + leaf.action, + parent.key_ptr.*, + ); + }, + } + } + + /// Get a binding for a given trigger. + pub fn get(self: Set, t: Trigger) ?Entry { + return self.bindings.getEntry(t); + } + + /// Get a trigger for the given action. An action can have multiple + /// triggers so this will return the first one found. + pub fn getTrigger(self: Set, a: Action) ?Trigger { + return self.reverse.get(a); + } + + /// Get an entry for the given key event. This will attempt to find + /// a binding using multiple parts of the event in the following order: + /// + /// 1. Translated key (event.key) + /// 2. Physical key (event.physical_key) + /// 3. Unshifted Unicode codepoint (event.unshifted_codepoint) + /// + pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry { + var trigger: Trigger = .{ + .mods = event.mods.binding(), + .key = .{ .physical = event.key }, + }; + if (self.get(trigger)) |v| return v; + + // If our UTF-8 text is exactly one codepoint, we try to match that. + if (event.utf8.len > 0) unicode: { + const view = std.unicode.Utf8View.init(event.utf8) catch break :unicode; + var it = view.iterator(); + + // No codepoints or multiple codepoints drops to invalid format + const cp = it.nextCodepoint() orelse break :unicode; + if (it.nextCodepoint() != null) break :unicode; + + trigger.key = .{ .unicode = cp }; + if (self.get(trigger)) |v| return v; + } + + // Finally fallback to the full unshifted codepoint if we have one. + // Question: should we be doing this if we have UTF-8 text? I + // suspect "no" but we don't currently have any failing scenarios + // to verify this. + if (event.unshifted_codepoint > 0) { + trigger.key = .{ .unicode = event.unshifted_codepoint }; + if (self.get(trigger)) |v| return v; + } + + // Fallback to catch_all with modifiers first, then without modifiers. + trigger.key = .catch_all; + if (self.get(trigger)) |v| return v; + if (!trigger.mods.empty()) { + trigger.mods = .{}; + if (self.get(trigger)) |v| return v; + } + + return null; + } + + /// Remove a binding for a given trigger. + pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void { + self.removeExact(alloc, t); + } + + fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { + // Removal always resets our chain parent. We could make this + // finer grained but the way it is documented is that chaining + // must happen directly after sets so this works. + self.chain_parent = null; + + var entry = self.bindings.get(t) orelse return; + _ = self.bindings.swapRemove(t); + + switch (entry) { + // For a leader removal, we need to deallocate our child set. + // Leaders are never part of reverse maps so no other accounting + // needs to be done. + .leader => |s| { + s.deinit(alloc); + alloc.destroy(s); + }, + + // For an action we need to fix up the reverse mapping. + .leaf => |leaf| self.fixupReverseForAction( + leaf.action, + t, + ), + + // Chained leaves are never in our reverse mapping so no + // cleanup is required. + .leaf_chained => |*l| { + l.deinit(alloc); + }, + } + } + + /// Fix up the reverse mapping after removing an action. + /// + /// When an action is removed from a binding (either by removal or by + /// converting to a chained action), we need to update the reverse mapping. + /// If another binding has the same action, we update the reverse mapping + /// to point to that binding. Otherwise, we remove the action from the + /// reverse mapping entirely. + /// + /// The `old` parameter is the trigger that was previously bound to this + /// action. It is used to check if the reverse mapping still points to + /// this trigger; if not, no fixup is needed since the reverse map already + /// points to a different trigger for this action. + /// + /// Note: we'd LIKE to replace this with the most recent binding but + /// our hash map obviously has no concept of ordering so we have to + /// choose whatever. Maybe a switch to an array hash map here. + fn fixupReverseForAction( + self: *Set, + action: Action, + old: Trigger, + ) void { + const entry = self.reverse.getEntry(action) orelse return; + + // If our value is not the same as the old trigger, we can + // ignore it because our reverse mapping points somewhere else. + if (!entry.value_ptr.equal(old)) return; + + // It is the same trigger, so let's now go through our bindings + // and try to find another trigger that maps to the same action. + const action_hash = action.hash(); + var it = self.bindings.iterator(); + while (it.next()) |it_entry| { + switch (it_entry.value_ptr.*) { + .leader, .leaf_chained => {}, + .leaf => |leaf_search| { + if (leaf_search.action.hash() == action_hash) { + entry.value_ptr.* = it_entry.key_ptr.*; + return; + } + }, + } + } + + // No other trigger points to this action so we remove + // the reverse mapping completely. + _ = self.reverse.swapRemove(action); + } + + /// Deep clone the set. + pub fn clone(self: *const Set, alloc: Allocator) !Set { + var result: Set = .{ + .bindings = try self.bindings.clone(alloc), + .reverse = try self.reverse.clone(alloc), + }; + + // If we have any leaders we need to clone them. + { + var it = result.bindings.iterator(); + while (it.next()) |entry| switch (entry.value_ptr.*) { + // Leaves could have data to clone (i.e. text actions + // contain allocated strings). + .leaf => |*s| s.* = try s.clone(alloc), + + .leaf_chained => |*s| s.* = try s.clone(alloc), + + // Must be deep cloned. + .leader => |*s| { + const ptr = try alloc.create(Set); + errdefer alloc.destroy(ptr); + ptr.* = try s.*.clone(alloc); + errdefer ptr.deinit(alloc); + s.* = ptr; + }, + }; + } + + // We need to clone the action keys in the reverse map since + // they may contain allocated values. + for (result.reverse.keys()) |*action| { + action.* = try action.clone(alloc); + } + + return result; + } + + /// The hash map context for the set. This defines how the hash map + /// gets the hash key and checks for equality. + fn Context(comptime KeyType: type) type { + return struct { + pub fn hash(ctx: @This(), k: KeyType) u32 { + _ = ctx; + // This seems crazy at first glance but this is also how + // the Zig standard library handles hashing for array + // hash maps! + return @truncate(k.hash()); + } + + pub fn eql( + ctx: @This(), + a: KeyType, + b: KeyType, + b_index: usize, + ) bool { + _ = ctx; + _ = b_index; + return a.bindingSetEqual(b); + } + }; + } +}; + +test "parse: triggers" { + const testing = std.testing; + + // single character + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = 'a' } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("a=ignore"), + ); + + // single modifier + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("shift+a=ignore")); + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .ctrl = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("ctrl+a=ignore")); + + // multiple modifier + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true, .ctrl = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("shift+ctrl+a=ignore")); + + // key can come before modifier + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("a+shift=ignore")); + + // physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .key_a }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("shift+key_a=ignore")); + + // unicode keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'ö' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("shift+ö=ignore")); + + // unconsumed keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .consumed = false }, + }, try parseSingle("unconsumed:shift+a=ignore")); + + // unconsumed physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .key_a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .consumed = false }, + }, try parseSingle("unconsumed:key_a+shift=ignore")); + + // performable keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .performable = true }, + }, try parseSingle("performable:shift+a=ignore")); + + // invalid key + try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore")); + + // repeated control + try testing.expectError(Error.InvalidFormat, parseSingle("shift+shift+a=ignore")); + + // multiple character + try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore")); +} + +test "parse: w3c key names" { + const testing = std.testing; + + // Exact match + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .physical = .key_a } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("KeyA=ignore"), + ); + + // Case-sensitive + try testing.expectError(Error.InvalidFormat, parseSingle("Keya=ignore")); +} + +test "parse: catch_all" { + const testing = std.testing; + + // Basic catch_all + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .catch_all }, + .action = .{ .ignore = {} }, + }, + try parseSingle("catch_all=ignore"), + ); + + // catch_all with modifiers + try testing.expectEqual( + Binding{ + .trigger = .{ + .mods = .{ .ctrl = true }, + .key = .catch_all, + }, + .action = .{ .ignore = {} }, + }, + try parseSingle("ctrl+catch_all=ignore"), + ); +} + +test "parse: plus sign" { + const testing = std.testing; + + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '+' } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("+=ignore"), + ); + + // Modifier + try testing.expectEqual( + Binding{ + .trigger = .{ + .key = .{ .unicode = '+' }, + .mods = .{ .ctrl = true }, + }, + .action = .{ .ignore = {} }, + }, + try parseSingle("ctrl++=ignore"), + ); + + try testing.expectError(Error.InvalidFormat, parseSingle("++=ignore")); +} + +test "parse: equals sign" { + const testing = std.testing; + + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '=' } }, + .action = .ignore, + }, + try parseSingle("==ignore"), + ); + + // Modifier + try testing.expectEqual( + Binding{ + .trigger = .{ + .key = .{ .unicode = '=' }, + .mods = .{ .ctrl = true }, + }, + .action = .ignore, + }, + try parseSingle("ctrl+==ignore"), + ); + + try testing.expectError(Error.InvalidFormat, parseSingle("=ignore")); +} + +test "parse: text action equals sign" { + const testing = std.testing; + { + const binding = try parseSingle("==text:="); + try testing.expectEqual(Trigger{ .key = .{ .unicode = '=' } }, binding.trigger); + try testing.expectEqualStrings("=", binding.action.text); + } + + { + const binding = try parseSingle("==text:=hello"); + try testing.expectEqual(Trigger{ .key = .{ .unicode = '=' } }, binding.trigger); + try testing.expectEqualStrings("=hello", binding.action.text); + } + + { + const binding = try parseSingle("ctrl+==text:=hello"); + try testing.expectEqual(Trigger{ + .key = .{ .unicode = '=' }, + .mods = .{ .ctrl = true }, + }, binding.trigger); + try testing.expectEqualStrings("=hello", binding.action.text); + } + + { + const binding = try parseSingle("=+ctrl=text:=hello"); + try testing.expectEqual(Trigger{ + .key = .{ .unicode = '=' }, + .mods = .{ .ctrl = true }, + }, binding.trigger); + try testing.expectEqualStrings("=hello", binding.action.text); + } +} + +// For Ghostty 1.2+ we changed our key names to match the W3C and removed +// `physical:`. This tests the backwards compatibility with the old format. +// Note that our backwards compatibility isn't 100% perfect since triggers +// like `a` now map to unicode instead of "translated" (which was also +// removed). But we did our best here with what was unambiguous. +test "parse: backwards compatibility with <= 1.1.x" { + const testing = std.testing; + + // simple, for sanity + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '0' } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("zero=ignore"), + ); + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .physical = .digit_0 } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("physical:zero=ignore"), + ); + + // duplicates + try testing.expectError(Error.InvalidFormat, parseSingle("zero+one=ignore")); + + // test our full map + for ( + Trigger.backwards_compatible_keys.keys(), + Trigger.backwards_compatible_keys.values(), + ) |k, v| { + var buf: [128]u8 = undefined; + try testing.expectEqual( + Binding{ + .trigger = .{ .key = v }, + .action = .{ .ignore = {} }, + }, + try parseSingle(try std.fmt.bufPrint(&buf, "{s}=ignore", .{k})), + ); + } +} + +test "parse: global triggers" { + const testing = std.testing; + + // global keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .global = true }, + }, try parseSingle("global:shift+a=ignore")); + + // global physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .key_a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .global = true }, + }, try parseSingle("global:key_a+shift=ignore")); + + // global unconsumed keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + .flags = .{ + .global = true, + .consumed = false, + }, + }, try parseSingle("unconsumed:global:a+shift=ignore")); + + // global sequences not allowed + { + var p = try Parser.init("global:a>b=ignore"); + try testing.expectError(Error.InvalidFormat, p.next()); + } +} + +test "parse: all triggers" { + const testing = std.testing; + + // all keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .all = true }, + }, try parseSingle("all:shift+a=ignore")); + + // all physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .key_a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .all = true }, + }, try parseSingle("all:key_a+shift=ignore")); + + // all unconsumed keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + .flags = .{ + .all = true, + .consumed = false, + }, + }, try parseSingle("unconsumed:all:a+shift=ignore")); + + // all sequences not allowed + { + var p = try Parser.init("all:a>b=ignore"); + try testing.expectError(Error.InvalidFormat, p.next()); + } +} + +test "Trigger: equal" { + const testing = std.testing; + + // Equal physical keys + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + try testing.expect(t1.equal(t2)); + } + + // Different physical keys + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true } }; + try testing.expect(!t1.equal(t2)); + } + + // Different mods + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .shift = true } }; + try testing.expect(!t1.equal(t2)); + } + + // Equal unicode keys + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + try testing.expect(t1.equal(t2)); + } + + // Different unicode keys + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .unicode = 'b' }, .mods = .{} }; + try testing.expect(!t1.equal(t2)); + } + + // Different key types + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .physical = .key_a }, .mods = .{} }; + try testing.expect(!t1.equal(t2)); + } + + // catch_all + { + const t1: Trigger = .{ .key = .catch_all, .mods = .{} }; + const t2: Trigger = .{ .key = .catch_all, .mods = .{} }; + try testing.expect(t1.equal(t2)); + } + + // catch_all with different mods + { + const t1: Trigger = .{ .key = .catch_all, .mods = .{} }; + const t2: Trigger = .{ .key = .catch_all, .mods = .{ .alt = true } }; + try testing.expect(!t1.equal(t2)); + } +} + +test "parse: modifier aliases" { + const testing = std.testing; + + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("cmd+a=ignore")); + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("command+a=ignore")); + + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .alt = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("opt+a=ignore")); + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .alt = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("option+a=ignore")); + + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .ctrl = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("control+a=ignore")); +} + +test "parse: action invalid" { + const testing = std.testing; + + // invalid action + try testing.expectError(Error.InvalidAction, parseSingle("a=nopenopenope")); +} + +test "parse: action no parameters" { + const testing = std.testing; + + // no parameters + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = 'a' } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("a=ignore"), + ); + try testing.expectError(Error.InvalidFormat, parseSingle("a=ignore:A")); +} + +test "parse: action with string" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=csi:A"); + try testing.expect(binding.action == .csi); + try testing.expectEqualStrings("A", binding.action.csi); + } + // parameter + { + const binding = try parseSingle("a=esc:A"); + try testing.expect(binding.action == .esc); + try testing.expectEqualStrings("A", binding.action.esc); + } + { + const binding = try parseSingle("a=set_surface_title:surface"); + try testing.expect(binding.action == .set_surface_title); + try testing.expectEqualStrings("surface", binding.action.set_surface_title); + } + { + const binding = try parseSingle("a=set_tab_title:tab"); + try testing.expect(binding.action == .set_tab_title); + try testing.expectEqualStrings("tab", binding.action.set_tab_title); + } +} + +test "parse: action with enum" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=new_split:right"); + try testing.expect(binding.action == .new_split); + try testing.expectEqual(Action.SplitDirection.right, binding.action.new_split); + } +} + +test "parse: action with enum with default" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=new_split"); + try testing.expect(binding.action == .new_split); + try testing.expectEqual(Action.SplitDirection.auto, binding.action.new_split); + } +} + +test "parse: action with int" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=jump_to_prompt:-1"); + try testing.expect(binding.action == .jump_to_prompt); + try testing.expectEqual(@as(i16, -1), binding.action.jump_to_prompt); + } + { + const binding = try parseSingle("a=jump_to_prompt:10"); + try testing.expect(binding.action == .jump_to_prompt); + try testing.expectEqual(@as(i16, 10), binding.action.jump_to_prompt); + } +} + +test "parse: action with float" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=scroll_page_fractional:-0.5"); + try testing.expect(binding.action == .scroll_page_fractional); + try testing.expectEqual(@as(f32, -0.5), binding.action.scroll_page_fractional); + } + { + const binding = try parseSingle("a=scroll_page_fractional:+0.5"); + try testing.expect(binding.action == .scroll_page_fractional); + try testing.expectEqual(@as(f32, 0.5), binding.action.scroll_page_fractional); + } +} + +test "parse: action with a tuple" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=resize_split:up,10"); + try testing.expect(binding.action == .resize_split); + try testing.expectEqual(Action.SplitResizeDirection.up, binding.action.resize_split[0]); + try testing.expectEqual(@as(u16, 10), binding.action.resize_split[1]); + } + + // missing parameter + try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up")); + + // too many + try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up,10,12")); + + // invalid type + try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up,four")); +} + +test "parse: chain" { + const testing = std.testing; + + // Valid + { + var p = try Parser.init("chain=new_tab"); + try testing.expectEqual(Parser.Elem{ + .chain = .new_tab, + }, try p.next()); + try testing.expect(try p.next() == null); + } + + // Chain can't have flags + try testing.expectError(error.InvalidFormat, Parser.init("global:chain=ignore")); + + // Chain can't be part of a sequence + { + var p = try Parser.init("a>chain=ignore"); + _ = try p.next(); + try testing.expectError(error.InvalidFormat, p.next()); + } +} + +test "sequence iterator" { + const testing = std.testing; + + // single character + { + var it: SequenceIterator = .{ .input = "a" }; + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); + try testing.expect(try it.next() == null); + } + + // multi character + { + var it: SequenceIterator = .{ .input = "a>b" }; + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'b' } }, (try it.next()).?); + try testing.expect(try it.next() == null); + } + + // empty + { + var it: SequenceIterator = .{ .input = "" }; + try testing.expectError(Error.InvalidFormat, it.next()); + } + + // empty starting sequence + { + var it: SequenceIterator = .{ .input = ">a" }; + try testing.expectError(Error.InvalidFormat, it.next()); + } + + // empty ending sequence + { + var it: SequenceIterator = .{ .input = "a>" }; + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); + try testing.expectError(Error.InvalidFormat, it.next()); + } +} + +test "parse: sequences" { + const testing = std.testing; + + // single character + { + var p = try Parser.init("ctrl+a=ignore"); + try testing.expectEqual(Parser.Elem{ .binding = .{ + .trigger = .{ + .mods = .{ .ctrl = true }, + .key = .{ .unicode = 'a' }, + }, + .action = .{ .ignore = {} }, + } }, (try p.next()).?); + try testing.expect(try p.next() == null); + } + + // sequence + { + var p = try Parser.init("a>b=ignore"); + try testing.expectEqual(Parser.Elem{ .leader = .{ + .key = .{ .unicode = 'a' }, + } }, (try p.next()).?); + try testing.expectEqual(Parser.Elem{ .binding = .{ + .trigger = .{ + .key = .{ .unicode = 'b' }, + }, + .action = .{ .ignore = {} }, + } }, (try p.next()).?); + try testing.expect(try p.next() == null); + } +} + +test "set: parseAndPut typical binding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + + // Creates forward mapping + { + const action = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + try testing.expectEqual(Flags{}, action.flags); + } + + // Creates reverse mapping + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } + + // Sets up the chain parent properly + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } +} + +test "set: parseAndPut unconsumed binding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "unconsumed:a=new_window"); + + // Creates forward mapping + { + const trigger: Trigger = .{ .key = .{ .unicode = 'a' } }; + const action = s.get(trigger).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + try testing.expectEqual(Flags{ .consumed = false }, action.flags); + } + + // Creates reverse mapping + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } + + // Sets up the chain parent properly + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } +} + +test "set: parseAndPut removed binding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "a=unbind"); + + // Creates forward mapping + { + const trigger: Trigger = .{ .key = .{ .unicode = 'a' } }; + try testing.expect(s.get(trigger) == null); + } + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); + + // Sets up the chain parent properly + try testing.expect(s.chain_parent == null); +} + +test "set: put sets chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // chain_parent should be set + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } + + // chain_parent value should be a leaf + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); +} + +test "set: putFlags sets chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); + + // chain_parent should be set + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } + + // chain_parent value should be a leaf with correct flags + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); + try testing.expect(!s.chain_parent.?.value_ptr.*.leaf.flags.consumed); +} + +test "set: sequence sets chain_parent to final leaf" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + + // chain_parent should be set and point to 'b' (the final leaf) + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("b", buf.written()); + } + + // chain_parent value should be a leaf + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); + try testing.expect(s.chain_parent.?.value_ptr.*.leaf.action == .new_window); +} + +test "set: multiple leaves under leader updates chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + + // After first binding, chain_parent should be 'b' + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("b", buf.written()); + } + + try s.parseAndPut(alloc, "a>c=new_tab"); + + // After second binding, chain_parent should be updated to 'c' + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("c", buf.written()); + } + try testing.expect(s.chain_parent.?.value_ptr.*.leaf.action == .new_tab); +} + +test "set: sequence unbind clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try testing.expect(s.chain_parent != null); + + try s.parseAndPut(alloc, "a>b=unbind"); + + // After unbind, chain_parent should be cleared + try testing.expect(s.chain_parent == null); +} + +test "set: sequence unbind with remaining leaves clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "a>c=new_tab"); + try s.parseAndPut(alloc, "a>b=unbind"); + + // After unbind, chain_parent should be cleared even though 'c' remains + try testing.expect(s.chain_parent == null); + + // But 'c' should still exist + const a_entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(a_entry.value_ptr.* == .leader); + const inner_set = a_entry.value_ptr.*.leader; + try testing.expect(inner_set.get(.{ .key = .{ .unicode = 'c' } }) != null); +} + +test "set: direct remove clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.chain_parent != null); + + s.remove(alloc, .{ .key = .{ .unicode = 'a' } }); + + // After removal, chain_parent should be cleared + try testing.expect(s.chain_parent == null); +} + +test "set: invalid format preserves chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + const before_key = s.chain_parent.?.key_ptr; + const before_value = s.chain_parent.?.value_ptr; + + // Try an invalid parse - should fail + try testing.expectError(error.InvalidAction, s.parseAndPut(alloc, "a=invalid_action_xyz")); + + // chain_parent should be unchanged + try testing.expect(s.chain_parent != null); + try testing.expect(s.chain_parent.?.key_ptr == before_key); + try testing.expect(s.chain_parent.?.value_ptr == before_value); +} + +test "set: clone produces null chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try testing.expect(s.chain_parent != null); + + var cloned = try s.clone(alloc); + defer cloned.deinit(alloc); + + // Clone should have null chain_parent + try testing.expect(cloned.chain_parent == null); + + // But should have the binding + try testing.expect(cloned.get(.{ .key = .{ .unicode = 'a' } }) != null); +} + +test "set: clone with leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding using parseAndPut with chain= + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Verify we have a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 2), entry.value_ptr.leaf_chained.actions.items.len); + + // Clone the set + var cloned = try s.clone(alloc); + defer cloned.deinit(alloc); + + // Verify the cloned set has the leaf_chained with same actions + const cloned_entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(cloned_entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 2), cloned_entry.value_ptr.leaf_chained.actions.items.len); + try testing.expect(cloned_entry.value_ptr.leaf_chained.actions.items[0] == .new_window); + try testing.expect(cloned_entry.value_ptr.leaf_chained.actions.items[1] == .new_tab); +} + +test "set: clone with leaf_chained containing allocated data" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var s: Set = .{}; + + // Create a chained binding with text actions (which have allocated strings) + try s.parseAndPut(alloc, "a=text:hello"); + try s.parseAndPut(alloc, "chain=text:world"); + + // Verify we have a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + // Clone the set + const cloned = try s.clone(alloc); + + // Verify the cloned set has independent copies of the text + const cloned_entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(cloned_entry.value_ptr.* == .leaf_chained); + try testing.expectEqualStrings("hello", cloned_entry.value_ptr.leaf_chained.actions.items[0].text); + try testing.expectEqualStrings("world", cloned_entry.value_ptr.leaf_chained.actions.items[1].text); + + // Verify the pointers are different (truly cloned, not shared) + try testing.expect(entry.value_ptr.leaf_chained.actions.items[0].text.ptr != + cloned_entry.value_ptr.leaf_chained.actions.items[0].text.ptr); +} + +test "set: parseAndPut sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leader); + current = e.leader; + } + { + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); + } +} + +test "set: parseAndPut sequence with two actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "a>c=new_tab"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leader); + current = e.leader; + } + { + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); + } + { + const t: Trigger = .{ .key = .{ .unicode = 'c' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_tab); + try testing.expectEqual(Flags{}, e.leaf.flags); + } +} + +test "set: parseAndPut overwrite sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_tab"); + try s.parseAndPut(alloc, "a>b=new_window"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leader); + current = e.leader; + } + { + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); + } +} + +test "set: parseAndPut overwrite leader" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_tab"); + try s.parseAndPut(alloc, "a>b=new_window"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leader); + current = e.leader; + } + { + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; + const e = current.get(t).?.value_ptr.*; + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); + } +} + +test "set: parseAndPut unbind sequence unbinds leader" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "a>b=unbind"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; + try testing.expect(current.get(t) == null); + } +} + +test "set: parseAndPut unbind sequence unbinds leader if not set" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=unbind"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; + try testing.expect(current.get(t) == null); + } +} + +test "set: parseAndPut sequence preserves reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "ctrl+a>b=new_window"); + + // Creates reverse mapping + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } +} + +test "set: put overwrites sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+a>b=new_window"); + try s.put(alloc, .{ + .mods = .{ .ctrl = true }, + .key = .{ .unicode = 'a' }, + }, .{ .new_window = {} }); + + // Creates reverse mapping + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } +} + +test "set: maintains reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } + + // should be most recent + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'b'); + } + + // removal should replace + s.remove(alloc, .{ .key = .{ .unicode = 'b' } }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } +} + +test "set: performable is not part of reverse mappings" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } + + // trigger should be non-performable + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'b' } }, + .{ .new_window = {} }, + .{ .performable = true }, + ); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } + + // removal of performable should do nothing + s.remove(alloc, .{ .key = .{ .unicode = 'b' } }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } +} + +test "set: overriding a mapping updates reverse" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } + + // should be most recent + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_tab = {} }); + { + const trigger = s.getTrigger(.{ .new_window = {} }); + try testing.expect(trigger == null); + } +} + +test "set: consumed state" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); + + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(!s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); +} + +test "set: parseAndPut chain" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Creates forward mapping as leaf_chained + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + } + + // Does not create reverse mapping, because reverse mappings are only for + // non-chained actions. + { + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); + } +} + +test "set: parseAndPut chain without parent is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Chain without a prior binding should fail + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=new_tab")); +} + +test "set: parseAndPut chain multiple times" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + + // Should have 3 actions chained + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expectEqual(@as(usize, 3), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + try testing.expect(chained.actions.items[2] == .close_surface); + } +} + +test "set: parseAndPut chain preserves flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "unconsumed:a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Should preserve unconsumed flag + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expect(!chained.flags.consumed); + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + } +} + +test "set: parseAndPut chain after unbind is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "a=unbind"); + + // Chain after unbind should fail because chain_parent is cleared + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=new_tab")); +} + +test "set: parseAndPut chain on sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Navigate to the inner set + const a_entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(a_entry == .leader); + const inner_set = a_entry.leader; + + // Check the chained binding + const b_entry = inner_set.get(.{ .key = .{ .unicode = 'b' } }).?.value_ptr.*; + try testing.expect(b_entry == .leaf_chained); + const chained = b_entry.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: parseAndPut chain with unbind is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + + // chain=unbind is not valid + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=unbind")); + + // Original binding should still exist + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + try testing.expect(entry.leaf.action == .new_window); +} + +test "set: getEvent physical" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+quote=new_window"); + + // Physical matches on physical + { + const action = s.getEvent(.{ + .key = .quote, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Physical does not match on UTF8/codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "'", + .unshifted_codepoint = '\'', + }); + try testing.expect(action == null); + } +} + +test "set: getEvent codepoint" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+'=new_window"); + + // Matches on codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = '\'', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Matches on UTF-8 + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "'", + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Doesn't match on physical + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }); + try testing.expect(action == null); + } +} + +test "set: getEvent codepoint case folding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+A=new_window"); + + // Lowercase codepoint + { + const action = s.getEvent(.{ + .key = .key_j, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 'a', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Uppercase codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 'A', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Negative case for sanity + { + const action = s.getEvent(.{ + .key = .key_j, + .mods = .{ .ctrl = true }, + }); + try testing.expect(action == null); + } +} + +test "set: getEvent catch_all fallback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "catch_all=ignore"); + + // Matches unbound key without modifiers + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{}, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Matches unbound key with modifiers (falls back to catch_all without mods) + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Specific binding takes precedence over catch_all + try s.parseAndPut(alloc, "ctrl+b=new_window"); + { + const action = s.getEvent(.{ + .key = .key_b, + .mods = .{ .ctrl = true }, + .unshifted_codepoint = 'b', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } +} + +test "set: getEvent catch_all with modifiers" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+catch_all=close_surface"); + try s.parseAndPut(alloc, "catch_all=ignore"); + + // Key with ctrl matches catch_all with ctrl modifier + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .close_surface); + } + + // Key without mods matches catch_all without mods + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{}, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Key with different mods falls back to catch_all without mods + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .alt = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } +} + +test "Action: clone" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var a: Action = .ignore; + const b = try a.clone(alloc); + try testing.expect(b == .ignore); + } + + { + var a: Action = .{ .text = "foo" }; + const b = try a.clone(alloc); + try testing.expect(b == .text); + } +} + +test "parse: increase_font_size" { + const testing = std.testing; + + { + const binding = try parseSingle("a=increase_font_size:1.5"); + try testing.expect(binding.action == .increase_font_size); + try testing.expectEqual(1.5, binding.action.increase_font_size); + } +} + +test "parse: decrease_font_size" { + const testing = std.testing; + + { + const binding = try parseSingle("a=decrease_font_size:2.5"); + try testing.expect(binding.action == .decrease_font_size); + try testing.expectEqual(2.5, binding.action.decrease_font_size); + } +} + +test "parse: reset_font_size" { + const testing = std.testing; + + { + const binding = try parseSingle("a=reset_font_size"); + try testing.expect(binding.action == .reset_font_size); + } +} + +test "parse: set_font_size" { + const testing = std.testing; + + { + const binding = try parseSingle("a=set_font_size:13.5"); + try testing.expect(binding.action == .set_font_size); + try testing.expectEqual(13.5, binding.action.set_font_size); + } +} + +test "parse: copy to clipboard default" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=copy_to_clipboard"); + try testing.expect(binding.action == .copy_to_clipboard); + try testing.expectEqual(Action.CopyToClipboard.mixed, binding.action.copy_to_clipboard); + } +} + +test "parse: copy to clipboard explicit" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=copy_to_clipboard:html"); + try testing.expect(binding.action == .copy_to_clipboard); + try testing.expectEqual(Action.CopyToClipboard.html, binding.action.copy_to_clipboard); + } +} + +test "parse: write screen file no format" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=write_screen_file:copy"); + try testing.expect(binding.action == .write_screen_file); + try testing.expectEqual(Action.WriteScreen.copy, binding.action.write_screen_file); + } +} + +test "parse: write screen file format" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=write_screen_file:copy,html"); + try testing.expect(binding.action == .write_screen_file); + try testing.expectEqual(Action.WriteScreen{ + .action = .copy, + .emit = .html, + }, binding.action.write_screen_file); + } +} + +test "parse: write screen file format as string" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + const binding = try parseSingle("a=write_screen_file:copy,html"); + try binding.action.format(&buf.writer); + try testing.expectEqualStrings("write_screen_file:copy,html", buf.written()); + } +} + +test "parse: write screen file invalid" { + const testing = std.testing; + + // paramet r + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:,", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:copy,", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:copy,html,extra", + )); +} + +test "action: format" { + const testing = std.testing; + const alloc = testing.allocator; + + const a: Action = .{ .text = "👻" }; + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try a.format(&buf.writer); + try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.written()); +} + +test "action: format set title" { + const testing = std.testing; + const alloc = testing.allocator; + + const a: Action = .{ .set_tab_title = "foo bar" }; + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try a.format(&buf.writer); + try testing.expectEqualStrings("set_tab_title:foo bar", buf.written()); +} + +test "set: appendChain with no parent returns error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try testing.expectError(error.NoChainParent, s.appendChain(alloc, .{ .new_tab = {} })); +} + +test "set: appendChain after put converts to leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // First appendChain converts leaf to leaf_chained and appends the new action + try s.appendChain(alloc, .{ .new_tab = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: appendChain after putFlags preserves flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); + try s.appendChain(alloc, .{ .new_tab = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expect(!chained.flags.consumed); + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: appendChain multiple times" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try s.appendChain(alloc, .{ .new_tab = {} }); + try s.appendChain(alloc, .{ .close_surface = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expectEqual(@as(usize, 3), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + try testing.expect(chained.actions.items[2] == .close_surface); +} + +test "set: appendChain removes reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // Verify reverse mapping exists before chaining + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); + + // Chaining should remove the reverse mapping + try s.appendChain(alloc, .{ .new_tab = {} }); + + // Reverse mapping should be gone since chained actions are not in reverse map + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); +} + +test "set: appendChain with performable does not affect reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Add a non-performable binding first + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); + + // Add a performable binding (not in reverse map) and chain it + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .close_surface = {} }, + .{ .performable = true }, + ); + + // close_surface was performable, so not in reverse map + try testing.expect(s.getTrigger(.{ .close_surface = {} }) == null); + + // Chaining the performable binding should not crash or affect anything + try s.appendChain(alloc, .{ .new_tab = {} }); + + // The non-performable new_window binding should still be in reverse map + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); +} + +test "set: appendChain restores next valid reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Add two bindings for the same action + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); + + // Reverse mapping should point to 'b' (most recent) + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'b'); + } + + // Chain an action to 'b', which should restore 'a' in the reverse map + try s.appendChain(alloc, .{ .new_tab = {} }); + + // Now reverse mapping should point to 'a' + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } +} + +test "set: formatEntries leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Verify it's a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + // Write the trigger first (as formatEntry in Config.zig does) + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = a=new_window + \\keybind = chain=new_tab + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} + +test "set: formatEntries leaf_chained multiple chains" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding with 3 actions + try s.parseAndPut(alloc, "ctrl+a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + + // Verify it's a leaf_chained with 3 actions + const entry = s.get(.{ .key = .{ .unicode = 'a' }, .mods = .{ .ctrl = true } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 3), entry.value_ptr.leaf_chained.actions.items.len); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = ctrl+a=new_window + \\keybind = chain=new_tab + \\keybind = chain=close_surface + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} + +test "set: formatEntries leaf_chained with text action" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding with text actions + try s.parseAndPut(alloc, "a=text:hello"); + try s.parseAndPut(alloc, "chain=text:world"); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = a=text:hello + \\keybind = chain=text:world + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} diff --git a/vendor/libghostty-vt/src/input/KeymapDarwin.zig b/vendor/libghostty-vt/src/input/KeymapDarwin.zig new file mode 100644 index 00000000..a8702730 --- /dev/null +++ b/vendor/libghostty-vt/src/input/KeymapDarwin.zig @@ -0,0 +1,289 @@ +// Keymap is responsible for translating keyboard inputs into localized chars. +/// +/// For example, the physical key "S" on a US-layout keyboard might mean "O" +/// in Dvorak. On international keyboard layouts, it may require multiple +/// keystrokes to produce a single character that is otherwise a single +/// keystroke on a US-layout keyboard. +/// +/// This information is critical to know for many reasons. For keybindings, +/// if a user configures "ctrl+o" to do something, it should work with the +/// physical "ctrl+S" key on a Dvorak keyboard and so on. +/// +/// This is currently only implemented for macOS. +const Keymap = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const macos = @import("macos"); +const codes = @import("keycodes.zig").entries; +const Mods = @import("key.zig").Mods; + +/// The current input source that is selected for the keyboard. This can +/// and does change whenever the user selects a new keyboard layout. This +/// change doesn't happen automatically; the user of this struct has to +/// detect it and then call `reload` to update the keymap. +source: *TISInputSource, + +/// The keyboard layout for the current input source. +/// +/// This doesn't need to be freed because its owned by the InputSource. +unicode_layout: *const UCKeyboardLayout, + +pub const Error = error{ + GetInputSourceFailed, + TranslateFailed, +}; + +/// The state that has to be passed in with each call to translate. +/// The contents of this are meant to mostly be opaque and can change +/// for platform-specific reasons. +pub const State = struct { + dead_key: u32 = 0, +}; + +/// The result of a translation. The result of a translation can be multiple +/// states. For example, if the user types a dead key, the result will be +/// "composing" since they're still in the process of composing a full +/// character. +pub const Translation = struct { + /// The translation result. If this is a dead key state, then this will + /// be pre-edit text that can be displayed but will ultimately be replaced. + text: []const u8 = "", + + /// Whether the text is still composing, i.e. this is a dead key state. + composing: bool = false, + + /// The mods that were consumed to produce this translation + mods: Mods = .{}, +}; + +pub fn init() !Keymap { + var keymap: Keymap = .{ .source = undefined, .unicode_layout = undefined }; + try keymap.reinit(); + return keymap; +} + +pub fn deinit(self: *const Keymap) void { + macos.foundation.CFRelease(self.source); +} + +/// Reload the keymap. This must be called if the user changes their +/// keyboard layout. +pub fn reload(self: *Keymap) !void { + macos.foundation.CFRelease(self.source); + try self.reinit(); +} + +/// Get the input source ID for the current keyboard layout. The input +/// source ID is a unique identifier for the keyboard layout which is uniquely +/// defined by Apple. +/// +/// This is macOS-only. Other platforms don't have an equivalent of this +/// so this isn't expected to be generally implemented. +pub fn sourceId(self: *const Keymap, buf: []u8) Allocator.Error![]const u8 { + // Get the raw CFStringRef + const id_raw = TISGetInputSourceProperty( + self.source, + kTISPropertyInputSourceID, + ) orelse return error.OutOfMemory; + + // Convert the CFStringRef to a C string into our buffer. + const id: *CFString = @ptrCast(id_raw); + return id.cstring(buf, .utf8) orelse error.OutOfMemory; +} + +/// Reinit reinitializes the keymap. It assumes that all the memory associated +/// with the keymap is already freed. +fn reinit(self: *Keymap) !void { + self.source = TISCopyCurrentKeyboardLayoutInputSource() orelse + return Error.GetInputSourceFailed; + + self.unicode_layout = layout: { + // This returns a CFDataRef + const data_raw = TISGetInputSourceProperty( + self.source, + kTISPropertyUnicodeKeyLayoutData, + ) orelse return Error.GetInputSourceFailed; + const data: *CFData = @ptrCast(data_raw); + + // The CFDataRef contains a UCKeyboardLayout pointer + break :layout @ptrCast(data.getPointer()); + }; + + if (comptime builtin.mode == .Debug) id: { + var buf: [256]u8 = undefined; + const id = self.sourceId(&buf) catch break :id; + std.log.debug("keyboard layout={s}", .{id}); + } +} + +/// Translate a single key input into a utf8 sequence. +pub fn translate( + self: *const Keymap, + out: []u8, + state: *State, + code: u16, + input_mods: Mods, +) !Translation { + // On macOS we strip ctrl because UCKeyTranslate + // converts to the masked values (i.e. ctrl+c becomes 3) + // and we don't want that behavior in Ghostty ever. This makes + // this file not a general-purpose keymap implementation. + const mods: Mods = mods: { + var v = input_mods; + v.ctrl = false; + break :mods v; + }; + + // Get the keycode for the space key, using comptime. + const code_space: u16 = comptime space: for (codes) |entry| { + if (std.mem.eql(u8, entry.code, "Space")) + break :space entry.native; + } else @compileError("space code not found"); + + // Convert our mods from our format to the Carbon API format + const modifier_state: u32 = (CarbonMods{ + .alt = if (mods.alt) true else false, + .ctrl = if (mods.ctrl) true else false, + .meta = if (mods.super) true else false, + .shift = if (mods.shift) true else false, + .caps_lock = if (mods.caps_lock) true else false, + }).ucKeyTranslate(); + + // We use 4 here because the Chromium source code uses 4 and Chrome + // works pretty well. They have a todo to look into longer sequences + // but given how mature that software is I think this is fine. + // + // From Chromium: + // Per Apple docs, the buffer length can be up to 255 but is rarely more than 4. + // https://developer.apple.com/documentation/coreservices/1390584-uckeytranslate + var char: [4]u16 = undefined; + var char_count: c_ulong = 0; + if (UCKeyTranslate( + self.unicode_layout, + code, + kUCKeyActionDown, + modifier_state, + LMGetKbdType(), + kUCKeyTranslateNoDeadKeysBit, + &state.dead_key, + char.len, + &char_count, + &char, + ) != 0) return Error.TranslateFailed; + + // If we got a dead key, then we translate again with "space" + // in order to get the pre-edit text. + const composing = if (state.dead_key != 0 and char_count == 0) composing: { + // We need to copy our dead key state so that it isn't modified. + var dead_key_ignore: u32 = state.dead_key; + if (UCKeyTranslate( + self.unicode_layout, + code_space, + kUCKeyActionDown, + modifier_state, + LMGetKbdType(), + kUCKeyTranslateNoDeadKeysMask, + &dead_key_ignore, + char.len, + &char_count, + &char, + ) != 0) return Error.TranslateFailed; + break :composing true; + } else false; + + // Convert the utf16 to utf8 + const len = try std.unicode.utf16LeToUtf8(out, char[0..char_count]); + return .{ + .text = out[0..len], + .composing = composing, + .mods = mods, + }; +} + +/// Map to the modifiers format used by the UCKeyTranslate function. +/// We use a u32 here because our bit arithmetic is all u32 anyways. +const CarbonMods = packed struct(u32) { + _padding_start: u8 = 0, + meta: bool = false, + shift: bool = false, + caps_lock: bool = false, + alt: bool = false, + ctrl: bool = false, + _padding_end: u19 = 0, + + /// Translate NSEventModifierFlags into the format used by UCKeyTranslate. + fn ucKeyTranslate(self: CarbonMods) u32 { + const int: u32 = @bitCast(self); + return (int >> 8) & 0xFF; + } + + // We got this from dumping the values out of another program since + // I can't find the header for this anywhere. I find various modifier + // headers but they do not match this! 🥺 + test "expected values" { + const testing = std.testing; + try testing.expectEqual(@as(u32, 0x100), @as(u32, @bitCast(CarbonMods{ .meta = true }))); + try testing.expectEqual(@as(u32, 0x200), @as(u32, @bitCast(CarbonMods{ .shift = true }))); + try testing.expectEqual(@as(u32, 0x400), @as(u32, @bitCast(CarbonMods{ .caps_lock = true }))); + try testing.expectEqual(@as(u32, 0x800), @as(u32, @bitCast(CarbonMods{ .alt = true }))); + try testing.expectEqual(@as(u32, 0x1000), @as(u32, @bitCast(CarbonMods{ .ctrl = true }))); + } +}; + +// The documentation for all of these types and functions is in the macOS SDK: +// Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/TextInputSources.h +extern "c" fn TISCopyCurrentKeyboardLayoutInputSource() ?*TISInputSource; +extern "c" fn TISGetInputSourceProperty(*TISInputSource, *CFString) ?*anyopaque; +extern "c" fn LMGetKbdLast() u8; +extern "c" fn LMGetKbdType() u8; +extern "c" fn UCKeyTranslate(*const UCKeyboardLayout, u16, u16, u32, u32, u32, *u32, c_ulong, *c_ulong, [*]u16) i32; +extern const kTISPropertyLocalizedName: *CFString; +extern const kTISPropertyUnicodeKeyLayoutData: *CFString; +extern const kTISPropertyInputSourceID: *CFString; +const TISInputSource = opaque {}; +const UCKeyboardLayout = opaque {}; +const kUCKeyActionDown: u16 = 0; +const kUCKeyActionUp: u16 = 1; +const kUCKeyActionAutoKey: u16 = 2; +const kUCKeyActionDisplay: u16 = 3; +const kUCKeyTranslateNoDeadKeysBit: u32 = 0; +const kUCKeyTranslateNoDeadKeysMask: u32 = 1 << kUCKeyTranslateNoDeadKeysBit; + +const CFData = macos.foundation.Data; +const CFString = macos.foundation.String; + +test { + var keymap = try init(); + defer keymap.deinit(); + + // These tests are all commented because they depend on the user-selected + // keyboard layout... + // + // // Single quote ' which is fine on US, but dead on US-International + // var buf: [4]u8 = undefined; + // var state: State = .{}; + // { + // const result = try keymap.translate(&buf, &state, 0x27, .{}); + // std.log.warn("map: text={s} dead={}", .{ result.text, result.composing }); + // } + // + // // Then type "a" which should combine with the dead key to make á + // { + // const result = try keymap.translate(&buf, &state, 0x00, .{}); + // std.log.warn("map: text={s} dead={}", .{ result.text, result.composing }); + // } + // + // // Shift+1 = ! on US + // { + // const result = try keymap.translate(&buf, &state, 0x12, .{ .shift = true }); + // std.log.warn("map: text={s} dead={}", .{ result.text, result.composing }); + // } + // + // // Scratch space + // { + // const result = try keymap.translate(&buf, &state, 0x00, .{ .ctrl = true }); + // std.log.warn("map: text={s} dead={}", .{ result.text, result.composing }); + // } +} diff --git a/vendor/libghostty-vt/src/input/KeymapNoop.zig b/vendor/libghostty-vt/src/input/KeymapNoop.zig new file mode 100644 index 00000000..b6a9d57b --- /dev/null +++ b/vendor/libghostty-vt/src/input/KeymapNoop.zig @@ -0,0 +1,39 @@ +//! A noop implementation of the keymap interface so that the embedded +//! library can compile on non-macOS platforms. +const KeymapNoop = @This(); + +const Mods = @import("key.zig").Mods; + +pub const State = struct {}; +pub const Translation = struct { + text: []const u8 = "", + composing: bool = false, + mods: Mods = .{}, +}; + +pub fn init() !KeymapNoop { + return .{}; +} + +pub fn deinit(self: *const KeymapNoop) void { + _ = self; +} + +pub fn reload(self: *KeymapNoop) !void { + _ = self; +} + +pub fn translate( + self: *const KeymapNoop, + out: []u8, + state: *State, + code: u16, + mods: Mods, +) !Translation { + _ = self; + _ = out; + _ = state; + _ = code; + _ = mods; + return .{ .text = "", .composing = false }; +} diff --git a/vendor/libghostty-vt/src/input/Link.zig b/vendor/libghostty-vt/src/input/Link.zig new file mode 100644 index 00000000..37b45dbd --- /dev/null +++ b/vendor/libghostty-vt/src/input/Link.zig @@ -0,0 +1,79 @@ +//! A link is a clickable element that can be used to trigger some action. +//! A link is NOT just a URL that opens in a browser. A link is any generic +//! regular expression match over terminal text that can trigger various +//! action types. +const Link = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const oni = @import("oniguruma"); +const Mods = @import("key.zig").Mods; + +/// The regular expression that will be used to match the link. Ownership +/// of this memory is up to the caller. The link will never free this memory. +regex: []const u8, + +/// The action that will be triggered when the link is clicked. +action: Action, + +/// The situations in which the link will be highlighted. A link is only +/// clickable by the mouse when it is highlighted, so this also controls +/// when the link is clickable. +highlight: Highlight, + +pub const Action = union(enum) { + /// Open the full matched value using the default open program. + /// For example, on macOS this is "open" and on Linux this is "xdg-open". + open: void, + + /// Open the OSC8 hyperlink under the mouse position. _-prefixed means + /// this can't be user-specified, it's only used internally. + _open_osc8: void, +}; + +pub const Highlight = union(enum) { + /// Always highlight the link. + always: void, + + /// Only highlight the link when the mouse is hovering over it. + hover: void, + + /// Highlight anytime the given mods are pressed, either when + /// hovering or always. For always, all links will be highlighted + /// when the mods are pressed regardless of if the mouse is hovering + /// over them. + /// + /// Note that if "shift" is specified here, this will NEVER match in + /// TUI programs that capture mouse events. "Shift" with mouse capture + /// escapes the mouse capture but strips the "shift" so it can't be + /// detected. + always_mods: Mods, + hover_mods: Mods, +}; + +/// Returns a new oni.Regex that can be used to match the link. +pub fn oniRegex(self: *const Link) !oni.Regex { + return try oni.Regex.init( + self.regex, + .{}, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); +} + +/// Deep clone the link. +pub fn clone(self: *const Link, alloc: Allocator) Allocator.Error!Link { + return .{ + .regex = try alloc.dupe(u8, self.regex), + .action = self.action, + .highlight = self.highlight, + }; +} + +/// Check if two links are equal. +pub fn equal(self: *const Link, other: *const Link) bool { + return std.meta.eql(self.action, other.action) and + std.meta.eql(self.highlight, other.highlight) and + std.mem.eql(u8, self.regex, other.regex); +} diff --git a/vendor/libghostty-vt/src/input/command.zig b/vendor/libghostty-vt/src/input/command.zig new file mode 100644 index 00000000..ac048eec --- /dev/null +++ b/vendor/libghostty-vt/src/input/command.zig @@ -0,0 +1,739 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const Action = @import("Binding.zig").Action; + +/// A command is a named binding action that can be executed from +/// something like a command palette. +/// +/// A command must be associated with a binding; all commands can be +/// mapped to traditional `keybind` configurations. This restriction +/// makes it so that there is nothing special about commands and likewise +/// it makes it trivial and consistent to define custom commands. +/// +/// For apprt implementers: a command palette doesn't have to make use +/// of all the fields here. We try to provide as much information as +/// possible to make it easier to implement a command palette in the way +/// that makes the most sense for the application. +pub const Command = struct { + action: Action, + title: [:0]const u8, + description: [:0]const u8 = "", + + /// ghostty_command_s + pub const C = extern struct { + action_key: [*:0]const u8, + action: [*:0]const u8, + title: [*:0]const u8, + description: [*:0]const u8, + }; + + pub fn clone(self: *const Command, alloc: Allocator) Allocator.Error!Command { + return .{ + .action = try self.action.clone(alloc), + .title = try alloc.dupeZ(u8, self.title), + .description = try alloc.dupeZ(u8, self.description), + }; + } + + pub fn equal(self: Command, other: Command) bool { + if (self.action.hash() != other.action.hash()) return false; + if (!std.mem.eql(u8, self.title, other.title)) return false; + if (!std.mem.eql(u8, self.description, other.description)) return false; + return true; + } + + /// Convert this command to a C struct at comptime. + pub fn comptimeCval(self: Command) C { + assert(@inComptime()); + + return .{ + .action_key = @tagName(self.action), + .action = std.fmt.comptimePrint("{f}", .{self.action}), + .title = self.title, + .description = self.description, + }; + } + + /// Convert this command to a C struct at runtime. + /// + /// This shares memory with the original command. + /// + /// The action string is allocated using the provided allocator. You can + /// free the slice directly if you need to but we recommend an arena + /// for this. + pub fn cval(self: Command, alloc: Allocator) Allocator.Error!C { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + self.action.format(&buf.writer) catch return error.OutOfMemory; + const action = try buf.toOwnedSliceSentinel(0); + + return .{ + .action_key = @tagName(self.action), + .action = action.ptr, + .title = self.title, + .description = self.description, + }; + } + + /// Implements a comparison function for std.mem.sortUnstable + /// and similar functions. The sorting is defined by Ghostty + /// to be what we prefer. If a caller wants some other sorting, + /// they should do it themselves. + pub fn lessThan(_: void, lhs: Command, rhs: Command) bool { + return std.ascii.orderIgnoreCase(lhs.title, rhs.title) == .lt; + } +}; + +pub const defaults: []const Command = defaults: { + @setEvalBranchQuota(100_000); + + var count: usize = 0; + for (@typeInfo(Action.Key).@"enum".fields) |field| { + const action = @field(Action.Key, field.name); + count += actionCommands(action).len; + } + + var result: [count]Command = undefined; + var i: usize = 0; + for (@typeInfo(Action.Key).@"enum".fields) |field| { + const action = @field(Action.Key, field.name); + const commands = actionCommands(action); + for (commands) |cmd| { + result[i] = cmd; + i += 1; + } + } + + std.mem.sortUnstable(Command, &result, {}, Command.lessThan); + + assert(i == count); + const final = result; + break :defaults &final; +}; + +/// Defaults in C-compatible form. +pub const defaultsC: []const Command.C = defaults: { + @setEvalBranchQuota(100_000); + var result: [defaults.len]Command.C = undefined; + for (defaults, 0..) |cmd, i| result[i] = cmd.comptimeCval(); + const final = result; + break :defaults &final; +}; + +/// Returns the set of commands associated with this action key by +/// default. Not all actions should have commands. As a general guideline, +/// an action should have a command only if it is useful and reasonable +/// to appear in a command palette. +fn actionCommands(action: Action.Key) []const Command { + // This is implemented as a function and switch rather than a + // flat comptime const because we want to ensure we get a compiler + // error when a new binding is added so that the contributor has + // to consider whether that new binding should have commands or not. + const result: []const Command = switch (action) { + // Note: the use of `comptime` prefix on the return values + // ensures that the data returned is all in the binary and + // and not pointing to the stack. + + .reset => comptime &.{.{ + .action = .reset, + .title = "Reset Terminal", + .description = "Reset the terminal to a clean state.", + }}, + + .copy_to_clipboard => comptime &.{ .{ + .action = .{ .copy_to_clipboard = .mixed }, + .title = "Copy to Clipboard", + .description = "Copy the selected text to the clipboard in both plain and styled formats.", + }, .{ + .action = .{ .copy_to_clipboard = .plain }, + .title = "Copy Selection as Plain Text to Clipboard", + .description = "Copy the selected text as plain text to the clipboard.", + }, .{ + .action = .{ .copy_to_clipboard = .vt }, + .title = "Copy Selection as ANSI Sequences to Clipboard", + .description = "Copy the selected text as ANSI escape sequences to the clipboard.", + }, .{ + .action = .{ .copy_to_clipboard = .html }, + .title = "Copy Selection as HTML to Clipboard", + .description = "Copy the selected text as HTML to the clipboard.", + } }, + + .copy_url_to_clipboard => comptime &.{.{ + .action = .copy_url_to_clipboard, + .title = "Copy URL to Clipboard", + .description = "Copy the URL under the cursor to the clipboard.", + }}, + + .copy_title_to_clipboard => comptime &.{.{ + .action = .copy_title_to_clipboard, + .title = "Copy Terminal Title to Clipboard", + .description = "Copy the terminal title to the clipboard. If the terminal title is not set this has no effect.", + }}, + + .paste_from_clipboard => comptime &.{.{ + .action = .paste_from_clipboard, + .title = "Paste from Clipboard", + .description = "Paste the contents of the main clipboard.", + }}, + + .paste_from_selection => comptime &.{.{ + .action = .paste_from_selection, + .title = "Paste from Selection", + .description = "Paste the contents of the selection clipboard.", + }}, + + .start_search => comptime &.{.{ + .action = .start_search, + .title = "Start Search", + .description = "Start a search if one isn't already active.", + }}, + + .search_selection => comptime &.{.{ + .action = .search_selection, + .title = "Search Selection", + .description = "Start a search for the current text selection.", + }}, + + .end_search => comptime &.{.{ + .action = .end_search, + .title = "End Search", + .description = "End the current search if any and hide any GUI elements.", + }}, + + .navigate_search => comptime &.{ .{ + .action = .{ .navigate_search = .next }, + .title = "Next Search Result", + .description = "Navigate to the next search result, if any.", + }, .{ + .action = .{ .navigate_search = .previous }, + .title = "Previous Search Result", + .description = "Navigate to the previous search result, if any.", + } }, + + .increase_font_size => comptime &.{.{ + .action = .{ .increase_font_size = 1 }, + .title = "Increase Font Size", + .description = "Increase the font size by 1 point.", + }}, + + .decrease_font_size => comptime &.{.{ + .action = .{ .decrease_font_size = 1 }, + .title = "Decrease Font Size", + .description = "Decrease the font size by 1 point.", + }}, + + .reset_font_size => comptime &.{.{ + .action = .reset_font_size, + .title = "Reset Font Size", + .description = "Reset the font size to the default.", + }}, + + .clear_screen => comptime &.{.{ + .action = .clear_screen, + .title = "Clear Screen", + .description = "Clear the screen and scrollback.", + }}, + + .select_all => comptime &.{.{ + .action = .select_all, + .title = "Select All", + .description = "Select all text on the screen.", + }}, + + .scroll_to_top => comptime &.{.{ + .action = .scroll_to_top, + .title = "Scroll to Top", + .description = "Scroll to the top of the screen.", + }}, + + .scroll_to_bottom => comptime &.{.{ + .action = .scroll_to_bottom, + .title = "Scroll to Bottom", + .description = "Scroll to the bottom of the screen.", + }}, + + .scroll_to_selection => comptime &.{.{ + .action = .scroll_to_selection, + .title = "Scroll to Selection", + .description = "Scroll to the selected text.", + }}, + + .scroll_page_up => comptime &.{.{ + .action = .scroll_page_up, + .title = "Scroll Page Up", + .description = "Scroll the screen up by a page.", + }}, + + .scroll_page_down => comptime &.{.{ + .action = .scroll_page_down, + .title = "Scroll Page Down", + .description = "Scroll the screen down by a page.", + }}, + + .write_screen_file => comptime &.{ + .{ + .action = .{ .write_screen_file = .copy }, + .title = "Copy Screen to Temporary File and Copy Path", + .description = "Copy the screen contents to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .paste }, + .title = "Copy Screen to Temporary File and Paste Path", + .description = "Copy the screen contents to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .open }, + .title = "Copy Screen to Temporary File and Open", + .description = "Copy the screen contents to a temporary file and open it.", + }, + + .{ + .action = .{ .write_screen_file = .{ + .action = .copy, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Copy Path", + .description = "Copy the screen contents as HTML to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .paste, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Paste Path", + .description = "Copy the screen contents as HTML to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .open, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Open", + .description = "Copy the screen contents as HTML to a temporary file and open it.", + }, + + .{ + .action = .{ .write_screen_file = .{ + .action = .copy, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Copy Path", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .paste, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Paste Path", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .open, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Open", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and open it.", + }, + }, + + .write_selection_file => comptime &.{ + .{ + .action = .{ .write_selection_file = .copy }, + .title = "Copy Selection to Temporary File and Copy Path", + .description = "Copy the selection contents to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .paste }, + .title = "Copy Selection to Temporary File and Paste Path", + .description = "Copy the selection contents to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .open }, + .title = "Copy Selection to Temporary File and Open", + .description = "Copy the selection contents to a temporary file and open it.", + }, + + .{ + .action = .{ .write_selection_file = .{ + .action = .copy, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Copy Path", + .description = "Copy the selection contents as HTML to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .paste, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Paste Path", + .description = "Copy the selection contents as HTML to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .open, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Open", + .description = "Copy the selection contents as HTML to a temporary file and open it.", + }, + + .{ + .action = .{ .write_selection_file = .{ + .action = .copy, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Copy Path", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .paste, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Paste Path", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .open, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Open", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and open it.", + }, + }, + + .new_window => comptime &.{.{ + .action = .new_window, + .title = "New Window", + .description = "Open a new window.", + }}, + + .new_tab => comptime &.{.{ + .action = .new_tab, + .title = "New Tab", + .description = "Open a new tab.", + }}, + + .move_tab => comptime &.{ + .{ + .action = .{ .move_tab = -1 }, + .title = "Move Tab Left", + .description = "Move the current tab to the left.", + }, + .{ + .action = .{ .move_tab = 1 }, + .title = "Move Tab Right", + .description = "Move the current tab to the right.", + }, + }, + + .toggle_tab_overview => comptime &.{.{ + .action = .toggle_tab_overview, + .title = "Toggle Tab Overview", + .description = "Toggle the tab overview.", + }}, + + .prompt_surface_title => comptime &.{.{ + .action = .prompt_surface_title, + .title = "Change Terminal Title…", + .description = "Prompt for a new title for the current terminal.", + }}, + + .prompt_tab_title => comptime &.{.{ + .action = .prompt_tab_title, + .title = "Change Tab Title…", + .description = "Prompt for a new title for the current tab.", + }}, + + .new_split => comptime &.{ + .{ + .action = .{ .new_split = .left }, + .title = "Split Left", + .description = "Split the terminal to the left.", + }, + .{ + .action = .{ .new_split = .right }, + .title = "Split Right", + .description = "Split the terminal to the right.", + }, + .{ + .action = .{ .new_split = .up }, + .title = "Split Up", + .description = "Split the terminal up.", + }, + .{ + .action = .{ .new_split = .down }, + .title = "Split Down", + .description = "Split the terminal down.", + }, + }, + + .goto_split => comptime &.{ + .{ + .action = .{ .goto_split = .previous }, + .title = "Focus Split: Previous", + .description = "Focus the previous split, if any.", + }, + .{ + .action = .{ .goto_split = .next }, + .title = "Focus Split: Next", + .description = "Focus the next split, if any.", + }, + .{ + .action = .{ .goto_split = .left }, + .title = "Focus Split: Left", + .description = "Focus the split to the left, if it exists.", + }, + .{ + .action = .{ .goto_split = .right }, + .title = "Focus Split: Right", + .description = "Focus the split to the right, if it exists.", + }, + .{ + .action = .{ .goto_split = .up }, + .title = "Focus Split: Up", + .description = "Focus the split above, if it exists.", + }, + .{ + .action = .{ .goto_split = .down }, + .title = "Focus Split: Down", + .description = "Focus the split below, if it exists.", + }, + }, + + .goto_window => comptime &.{ + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Previous", + .description = "Focus the previous window, if any.", + }, + .{ + .action = .{ .goto_window = .next }, + .title = "Focus Window: Next", + .description = "Focus the next window, if any.", + }, + }, + + .toggle_split_zoom => comptime &.{.{ + .action = .toggle_split_zoom, + .title = "Toggle Split Zoom", + .description = "Toggle the zoom state of the current split.", + }}, + + .toggle_readonly => comptime &.{.{ + .action = .toggle_readonly, + .title = "Toggle Read-Only Mode", + .description = "Toggle read-only mode for the current surface.", + }}, + + .equalize_splits => comptime &.{.{ + .action = .equalize_splits, + .title = "Equalize Splits", + .description = "Equalize the size of all splits.", + }}, + + .reset_window_size => comptime &.{.{ + .action = .reset_window_size, + .title = "Reset Window Size", + .description = "Reset the window size to the default.", + }}, + + .inspector => comptime &.{.{ + .action = .{ .inspector = .toggle }, + .title = "Toggle Inspector", + .description = "Toggle the inspector.", + }}, + + .show_gtk_inspector => comptime &.{.{ + .action = .show_gtk_inspector, + .title = "Show the GTK Inspector", + .description = "Show the GTK inspector.", + }}, + + .show_on_screen_keyboard => comptime &.{.{ + .action = .show_on_screen_keyboard, + .title = "Show On-Screen Keyboard", + .description = "Show the on-screen keyboard if present.", + }}, + + .open_config => comptime &.{.{ + .action = .open_config, + .title = "Open Config", + .description = "Open the config file.", + }}, + + .reload_config => comptime &.{.{ + .action = .reload_config, + .title = "Reload Config", + .description = "Reload the config file.", + }}, + + .close_surface => comptime &.{.{ + .action = .close_surface, + .title = "Close Terminal", + .description = "Close the current terminal.", + }}, + + .close_tab => comptime &.{ + .{ + .action = .{ .close_tab = .this }, + .title = "Close Tab", + .description = "Close the current tab.", + }, + .{ + .action = .{ .close_tab = .other }, + .title = "Close Other Tabs", + .description = "Close all tabs in this window except the current one.", + }, + .{ + .action = .{ .close_tab = .right }, + .title = "Close Tabs to the Right", + .description = "Close all tabs to the right of the current one.", + }, + }, + + .close_window => comptime &.{.{ + .action = .close_window, + .title = "Close Window", + .description = "Close the current window.", + }}, + + .close_all_windows => comptime &.{.{ + .action = .close_all_windows, + .title = "Close All Windows", + .description = "Close all windows.", + }}, + + .toggle_maximize => comptime &.{.{ + .action = .toggle_maximize, + .title = "Toggle Maximize", + .description = "Toggle the maximized state of the current window.", + }}, + + .toggle_fullscreen => comptime &.{.{ + .action = .toggle_fullscreen, + .title = "Toggle Fullscreen", + .description = "Toggle the fullscreen state of the current window.", + }}, + + .toggle_window_decorations => comptime &.{.{ + .action = .toggle_window_decorations, + .title = "Toggle Window Decorations", + .description = "Toggle the window decorations.", + }}, + + .toggle_window_float_on_top => comptime &.{.{ + .action = .toggle_window_float_on_top, + .title = "Toggle Float on Top", + .description = "Toggle the float on top state of the current window.", + }}, + + .toggle_secure_input => comptime &.{.{ + .action = .toggle_secure_input, + .title = "Toggle Secure Input", + .description = "Toggle secure input mode.", + }}, + + .toggle_mouse_reporting => comptime &.{.{ + .action = .toggle_mouse_reporting, + .title = "Toggle Mouse Reporting", + .description = "Toggle whether mouse events are reported to terminal applications.", + }}, + + .toggle_background_opacity => comptime &.{.{ + .action = .toggle_background_opacity, + .title = "Toggle Background Opacity", + .description = "Toggle the background opacity of a window that started transparent.", + }}, + + .check_for_updates => comptime &.{.{ + .action = .check_for_updates, + .title = "Check for Updates", + .description = "Check for updates to the application.", + }}, + + .undo => comptime &.{.{ + .action = .undo, + .title = "Undo", + .description = "Undo the last action.", + }}, + + .redo => comptime &.{.{ + .action = .redo, + .title = "Redo", + .description = "Redo the last undone action.", + }}, + + .quit => comptime &.{.{ + .action = .quit, + .title = "Quit", + .description = "Quit the application.", + }}, + + .text => comptime &.{.{ + .action = .{ .text = "👻" }, + .title = "Ghostty", + .description = "Put a little Ghostty in your terminal.", + }}, + + // No commands because they're parameterized and there + // aren't obvious values users would use. It is possible that + // these may have commands in the future if there are very + // common values that users tend to use. + .csi, + .esc, + .cursor_key, + .set_font_size, + .set_surface_title, + .set_tab_title, + .search, + .scroll_to_row, + .scroll_page_fractional, + .scroll_page_lines, + .adjust_selection, + .jump_to_prompt, + .write_scrollback_file, + .goto_tab, + .resize_split, + .activate_key_table, + .activate_key_table_once, + .deactivate_key_table, + .deactivate_all_key_tables, + .end_key_sequence, + .crash, + => comptime &.{}, + + // No commands because I'm not sure they make sense in a command + // palette context. + .toggle_command_palette, + .toggle_quick_terminal, + .toggle_visibility, + .previous_tab, + .next_tab, + .last_tab, + => comptime &.{}, + + // No commands for obvious reasons + .ignore, + .unbind, + => comptime &.{}, + }; + + // All generated commands should have the same action as the + // action passed in. + for (result) |cmd| assert(cmd.action == action); + + return result; +} + +test "command defaults" { + // This just ensures that defaults is analyzed and works. + const testing = std.testing; + try testing.expect(defaults.len > 0); + try testing.expectEqual(defaults.len, defaultsC.len); +} diff --git a/vendor/libghostty-vt/src/input/config.zig b/vendor/libghostty-vt/src/input/config.zig new file mode 100644 index 00000000..fd839a20 --- /dev/null +++ b/vendor/libghostty-vt/src/input/config.zig @@ -0,0 +1,8 @@ +/// Determines the macOS option key behavior. See the config +/// `macos-option-as-alt` for a lot more details. +pub const OptionAsAlt = enum(c_int) { + false, + true, + left, + right, +}; diff --git a/vendor/libghostty-vt/src/input/function_keys.zig b/vendor/libghostty-vt/src/input/function_keys.zig new file mode 100644 index 00000000..efe86d9e --- /dev/null +++ b/vendor/libghostty-vt/src/input/function_keys.zig @@ -0,0 +1,312 @@ +//! This is the list of "PC style function keys" that xterm supports for +//! the legacy keyboard protocols. These always take priority since at the +//! time of writing this, even the most modern keyboard protocols still +//! are backwards compatible with regards to these sequences. +//! +//! This is based on a variety of sources cross-referenced but mostly +//! based on foot's keymap.h: https://codeberg.org/dnkl/foot/src/branch/master/keymap.h + +const std = @import("std"); +const key = @import("key.zig"); + +pub const CursorMode = enum { any, normal, application }; +pub const KeypadMode = enum { any, normal, application }; + +/// A bit confusing so I'll document this one: this is the "modify other keys" +/// setting. We only change behavior for "set_other" which is ESC [ > 4; 2 m. +/// So this can be "any" which means we don't care what's going on. Or it +/// can be "set" which means modify keys must be set EXCEPT FOR "other keys" +/// mode, and "set_other" which means modify keys must be set to "other keys" +/// mode. +/// +/// See: https://invisible-island.net/xterm/modified-keys.html +pub const ModifyKeys = enum { + any, + set, + set_other, +}; + +/// A single entry in the table of keys. +pub const Entry = struct { + /// The exact set of modifiers that must be active for this entry to match. + /// If mods_empty_is_any is true then empty mods means any set of mods can + /// match. Otherwise, empty mods means no mods must be active. + mods: key.Mods = .{}, + mods_empty_is_any: bool = true, + + /// The state required for cursor/keypad mode. + cursor: CursorMode = .any, + keypad: KeypadMode = .any, + + /// Whether or not this entry should be used + modify_other_keys: ModifyKeys = .any, + + /// The sequence to send to the pty if this entry matches. + sequence: []const u8, +}; + +/// The list of modifier combinations for modify other key sequences. +/// The mode value is index + 2. +pub const modifiers: []const key.Mods = &.{ + .{ .shift = true }, + .{ .alt = true }, + .{ .shift = true, .alt = true }, + .{ .ctrl = true }, + .{ .shift = true, .ctrl = true }, + .{ .alt = true, .ctrl = true }, + .{ .shift = true, .alt = true, .ctrl = true }, + .{ .super = true }, + .{ .shift = true, .super = true }, + .{ .alt = true, .super = true }, + .{ .shift = true, .alt = true, .super = true }, + .{ .ctrl = true, .super = true }, + .{ .shift = true, .ctrl = true, .super = true }, + .{ .alt = true, .ctrl = true, .super = true }, + .{ .shift = true, .alt = true, .ctrl = true, .super = true }, +}; + +/// This is the array of entries for the PC style function keys as mapped to +/// our set of possible key codes. Not every key has any entries. +pub const KeyEntryArray = std.EnumArray(key.Key, []const Entry); + +// The list of keys that we support and their entry values. It is expected +// that you'll just use a for loop through the entry values, there are never +// more than a couple dozen or so. +pub const keys = keys: { + var result = KeyEntryArray.initFill(&.{}); + + result.set(.arrow_up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); + result.set(.arrow_down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); + result.set(.arrow_right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); + result.set(.arrow_left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); + result.set(.home, pcStyle("\x1b[1;{}H") ++ cursorKey("\x1b[H", "\x1bOH")); + result.set(.end, pcStyle("\x1b[1;{}F") ++ cursorKey("\x1b[F", "\x1bOF")); + result.set(.insert, pcStyle("\x1b[2;{}~") ++ .{Entry{ .sequence = "\x1B[2~" }}); + result.set(.delete, pcStyle("\x1b[3;{}~") ++ .{Entry{ .sequence = "\x1B[3~" }}); + result.set(.page_up, pcStyle("\x1b[5;{}~") ++ .{Entry{ .sequence = "\x1B[5~" }}); + result.set(.page_down, pcStyle("\x1b[6;{}~") ++ .{Entry{ .sequence = "\x1B[6~" }}); + + // Function Keys. todo: f13-f35 but we need to add to input.Key + result.set(.f1, pcStyle("\x1b[1;{}P") ++ .{Entry{ .sequence = "\x1BOP" }}); + result.set(.f2, pcStyle("\x1b[1;{}Q") ++ .{Entry{ .sequence = "\x1BOQ" }}); + result.set(.f3, pcStyle("\x1b[13;{}~") ++ .{Entry{ .sequence = "\x1BOR" }}); + result.set(.f4, pcStyle("\x1b[1;{}S") ++ .{Entry{ .sequence = "\x1BOS" }}); + result.set(.f5, pcStyle("\x1b[15;{}~") ++ .{Entry{ .sequence = "\x1B[15~" }}); + result.set(.f6, pcStyle("\x1b[17;{}~") ++ .{Entry{ .sequence = "\x1B[17~" }}); + result.set(.f7, pcStyle("\x1b[18;{}~") ++ .{Entry{ .sequence = "\x1B[18~" }}); + result.set(.f8, pcStyle("\x1b[19;{}~") ++ .{Entry{ .sequence = "\x1B[19~" }}); + result.set(.f9, pcStyle("\x1b[20;{}~") ++ .{Entry{ .sequence = "\x1B[20~" }}); + result.set(.f10, pcStyle("\x1b[21;{}~") ++ .{Entry{ .sequence = "\x1B[21~" }}); + result.set(.f11, pcStyle("\x1b[23;{}~") ++ .{Entry{ .sequence = "\x1B[23~" }}); + result.set(.f12, pcStyle("\x1b[24;{}~") ++ .{Entry{ .sequence = "\x1B[24~" }}); + + // Keypad keys + result.set(.numpad_0, kpKeys("p")); + result.set(.numpad_1, kpKeys("q")); + result.set(.numpad_2, kpKeys("r")); + result.set(.numpad_3, kpKeys("s")); + result.set(.numpad_4, kpKeys("t")); + result.set(.numpad_5, kpKeys("u")); + result.set(.numpad_6, kpKeys("v")); + result.set(.numpad_7, kpKeys("w")); + result.set(.numpad_8, kpKeys("x")); + result.set(.numpad_9, kpKeys("y")); + result.set(.numpad_decimal, kpKeys("n")); + result.set(.numpad_divide, kpKeys("o")); + result.set(.numpad_multiply, kpKeys("j")); + result.set(.numpad_subtract, kpKeys("m")); + result.set(.numpad_add, kpKeys("k")); + result.set(.numpad_enter, kpKeys("M") ++ .{Entry{ .sequence = "\r" }}); + result.set(.numpad_up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); + result.set(.numpad_down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); + result.set(.numpad_right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); + result.set(.numpad_left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); + result.set(.numpad_begin, pcStyle("\x1b[1;{}E") ++ cursorKey("\x1b[E", "\x1bOE")); + result.set(.numpad_home, pcStyle("\x1b[1;{}H") ++ cursorKey("\x1b[H", "\x1bOH")); + result.set(.numpad_end, pcStyle("\x1b[1;{}F") ++ cursorKey("\x1b[F", "\x1bOF")); + result.set(.numpad_insert, pcStyle("\x1b[2;{}~") ++ .{Entry{ .sequence = "\x1B[2~" }}); + result.set(.numpad_delete, pcStyle("\x1b[3;{}~") ++ .{Entry{ .sequence = "\x1B[3~" }}); + result.set(.numpad_page_up, pcStyle("\x1b[5;{}~") ++ .{Entry{ .sequence = "\x1B[5~" }}); + result.set(.numpad_page_down, pcStyle("\x1b[6;{}~") ++ .{Entry{ .sequence = "\x1B[6~" }}); + + result.set(.backspace, &.{ + // Modify Keys Normal + .{ .mods = .{ .shift = true }, .modify_other_keys = .set, .sequence = "\x7f" }, + .{ .mods = .{ .alt = true }, .modify_other_keys = .set, .sequence = "\x1b\x7f" }, + .{ .mods = .{ .alt = true, .shift = true }, .modify_other_keys = .set, .sequence = "\x1b\x7f" }, + .{ .mods = .{ .ctrl = true, .shift = true }, .modify_other_keys = .set, .sequence = "\x08" }, + .{ .mods = .{ .alt = true, .ctrl = true }, .modify_other_keys = .set, .sequence = "\x1b\x08" }, + .{ .mods = .{ .super = true }, .modify_other_keys = .set, .sequence = "\x7f" }, + .{ .mods = .{ .super = true, .shift = true }, .modify_other_keys = .set, .sequence = "\x7f" }, + .{ .mods = .{ .alt = true, .super = true }, .modify_other_keys = .set, .sequence = "\x1b\x7f" }, + .{ .mods = .{ .alt = true, .super = true, .shift = true }, .modify_other_keys = .set, .sequence = "\x1b\x7f" }, + .{ .mods = .{ .super = true, .ctrl = true }, .modify_other_keys = .set, .sequence = "\x08" }, + .{ .mods = .{ .super = true, .ctrl = true, .shift = true }, .modify_other_keys = .set, .sequence = "\x08" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true }, .modify_other_keys = .set, .sequence = "\x1b\x08" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true, .shift = true }, .modify_other_keys = .set, .sequence = "\x1b\x08" }, + + // Modify Keys Other + .{ .mods = .{ .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;2;127~" }, + .{ .mods = .{ .alt = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;3;127~" }, + .{ .mods = .{ .alt = true, .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;4;127~" }, + .{ .mods = .{ .ctrl = true, .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;6;127~" }, + .{ .mods = .{ .alt = true, .ctrl = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;7;127~" }, + .{ .mods = .{ .alt = true, .shift = true, .ctrl = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;8;127~" }, + .{ .mods = .{ .super = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;9;127~" }, + .{ .mods = .{ .super = true, .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;10;127~" }, + .{ .mods = .{ .alt = true, .super = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;11;127~" }, + .{ .mods = .{ .alt = true, .super = true, .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;12;127~" }, + .{ .mods = .{ .super = true, .ctrl = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;13;127~" }, + .{ .mods = .{ .super = true, .ctrl = true, .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;14;127~" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;15;127~" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true, .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;16;127~" }, + + .{ .mods = .{ .ctrl = true }, .sequence = "\x08" }, + .{ .sequence = "\x7f" }, + }); + + result.set(.tab, &.{ + // Modify Keys Normal + .{ .mods = .{ .shift = true }, .modify_other_keys = .set, .sequence = "\x1b[Z" }, + .{ .mods = .{ .alt = true }, .modify_other_keys = .set, .sequence = "\x1b\t" }, + + // Modify Keys Other + .{ .mods = .{ .shift = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;2;9~" }, + .{ .mods = .{ .alt = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;3;9~" }, + + // Everything else + .{ .mods = .{ .alt = true, .shift = true }, .sequence = "\x1b[27;4;9~" }, + .{ .mods = .{ .ctrl = true }, .sequence = "\x1b[27;5;9~" }, + .{ .mods = .{ .ctrl = true, .shift = true }, .sequence = "\x1b[27;6;9~" }, + .{ .mods = .{ .alt = true, .ctrl = true }, .sequence = "\x1b[27;7;9~" }, + .{ .mods = .{ .alt = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;8;9~" }, + .{ .mods = .{ .super = true }, .sequence = "\x1b[27;9;9~" }, + .{ .mods = .{ .super = true, .shift = true }, .sequence = "\x1b[27;10;9~" }, + .{ .mods = .{ .alt = true, .super = true }, .sequence = "\x1b[27;11;9~" }, + .{ .mods = .{ .alt = true, .super = true, .shift = true }, .sequence = "\x1b[27;12;9~" }, + .{ .mods = .{ .super = true, .ctrl = true }, .sequence = "\x1b[27;13;9~" }, + .{ .mods = .{ .super = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;14;9~" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true }, .sequence = "\x1b[27;15;9~" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;16;9~" }, + + .{ .sequence = "\t" }, + }); + + result.set(.enter, &.{ + .{ .mods = .{ .shift = true }, .sequence = "\x1b[27;2;13~" }, + + // Modify Keys Normal + .{ .mods = .{ .alt = true }, .modify_other_keys = .set, .sequence = "\x1b\r" }, + + // Modify Keys Other + .{ .mods = .{ .alt = true }, .modify_other_keys = .set_other, .sequence = "\x1b[27;3;13~" }, + + // Everything else + .{ .mods = .{ .alt = true, .shift = true }, .sequence = "\x1b[27;4;13~" }, + .{ .mods = .{ .ctrl = true }, .sequence = "\x1b[27;5;13~" }, + .{ .mods = .{ .ctrl = true, .shift = true }, .sequence = "\x1b[27;6;13~" }, + .{ .mods = .{ .alt = true, .ctrl = true }, .sequence = "\x1b[27;7;13~" }, + .{ .mods = .{ .alt = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;8;13~" }, + .{ .mods = .{ .super = true }, .sequence = "\x1b[27;9;13~" }, + .{ .mods = .{ .super = true, .shift = true }, .sequence = "\x1b[27;10;13~" }, + .{ .mods = .{ .alt = true, .super = true }, .sequence = "\x1b[27;11;13~" }, + .{ .mods = .{ .alt = true, .super = true, .shift = true }, .sequence = "\x1b[27;12;13~" }, + .{ .mods = .{ .super = true, .ctrl = true }, .sequence = "\x1b[27;13;13~" }, + .{ .mods = .{ .super = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;14;13~" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true }, .sequence = "\x1b[27;15;13~" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;16;13~" }, + + .{ .sequence = "\r" }, + }); + + result.set(.escape, &.{ + .{ .mods = .{ .shift = true }, .sequence = "\x1b[27;2;27~" }, + .{ .mods = .{ .alt = true }, .sequence = "\x1b\x1b" }, + .{ .mods = .{ .alt = true, .shift = true }, .sequence = "\x1b[27;4;27~" }, + .{ .mods = .{ .ctrl = true }, .sequence = "\x1b[27;5;27~" }, + .{ .mods = .{ .ctrl = true, .shift = true }, .sequence = "\x1b[27;6;27~" }, + .{ .mods = .{ .alt = true, .ctrl = true }, .sequence = "\x1b[27;7;27~" }, + .{ .mods = .{ .alt = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;8;27~" }, + .{ .mods = .{ .super = true }, .sequence = "\x1b[27;9;27~" }, + .{ .mods = .{ .super = true, .shift = true }, .sequence = "\x1b[27;10;27~" }, + .{ .mods = .{ .alt = true, .super = true }, .sequence = "\x1b[27;11;27~" }, + .{ .mods = .{ .alt = true, .super = true, .shift = true }, .sequence = "\x1b[27;12;27~" }, + .{ .mods = .{ .super = true, .ctrl = true }, .sequence = "\x1b[27;13;27~" }, + .{ .mods = .{ .super = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;14;27~" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true }, .sequence = "\x1b[27;15;27~" }, + .{ .mods = .{ .alt = true, .super = true, .ctrl = true, .shift = true }, .sequence = "\x1b[27;16;27~" }, + .{ .sequence = "\x1b" }, + }); + + break :keys result; +}; + +/// Returns the default keypad application mode entry. +fn kpDefault(comptime suffix: []const u8) []const Entry { + return &.{ + .{ + .mods_empty_is_any = false, + .keypad = .application, + .sequence = "\x1bO" ++ suffix, + }, + }; +} + +/// Returns the entries for a keypad key. The suffix is the final character +/// of the sent sequence, such as "r" for kp_2. +fn kpKeys(comptime suffix: []const u8) []const Entry { + const pc = pcStyle("\x1bO{}" ++ suffix); + for (pc) |*entry| entry.keypad = .application; + return kpDefault(suffix) ++ pc; +} + +/// Returns entries that are dependent on cursor key settings. +fn cursorKey( + comptime normal: []const u8, + comptime application: []const u8, +) []const Entry { + return &.{ + .{ .cursor = .normal, .sequence = normal }, + .{ .cursor = .application, .sequence = application }, + }; +} + +/// Constructs a set of pcStyle function keys using the given format. The +/// format should have exactly one "hole" for the mods code. +/// Example: "\x1b[11;{}~" for F1. +fn pcStyle(comptime fmt: []const u8) []Entry { + // The comptime {} wrapper is superfluous but it prevents us from + // accidentally running this function at runtime. + comptime { + @setEvalBranchQuota(500_000); + var entries: [modifiers.len]Entry = undefined; + for (modifiers, 2.., 0..) |mods, code, i| { + entries[i] = .{ + .mods = mods, + .sequence = std.fmt.comptimePrint(fmt, .{code}), + }; + } + + return &entries; + } +} + +test "keys" { + const testing = std.testing; + switch (@import("terminal_options").artifact) { + .ghostty => {}, + // Don't want to bring in termio into libghostty-vt + .lib => return error.SkipZigTest, + } + + // Force resolution for comptime evaluation. + _ = keys; + + // All key sequences must fit into a termio array. + const max = @import("../termio.zig").Message.WriteReq.Small.Max; + for (keys.values) |entries| { + for (entries) |entry| { + try testing.expect(entry.sequence.len <= max); + } + } +} diff --git a/vendor/libghostty-vt/src/input/helpgen_actions.zig b/vendor/libghostty-vt/src/input/helpgen_actions.zig new file mode 100644 index 00000000..4210f1f9 --- /dev/null +++ b/vendor/libghostty-vt/src/input/helpgen_actions.zig @@ -0,0 +1,112 @@ +//! This module is a help generator for keybind actions documentation. +//! It can generate documentation in different formats (plaintext for CLI, +//! markdown for website) while maintaining consistent content. + +const std = @import("std"); +const KeybindAction = @import("Binding.zig").Action; +const help_strings = @import("help_strings"); + +/// Format options for generating keybind actions documentation +pub const Format = enum { + /// Plain text output with indentation + plaintext, + /// Markdown formatted output + markdown, + + fn formatFieldName(self: Format, writer: *std.Io.Writer, field_name: []const u8) !void { + switch (self) { + .plaintext => { + try writer.writeAll(field_name); + try writer.writeAll(":\n"); + }, + .markdown => { + try writer.writeAll("## `"); + try writer.writeAll(field_name); + try writer.writeAll("`\n"); + }, + } + } + + fn formatDocLine(self: Format, writer: *std.Io.Writer, line: []const u8) !void { + switch (self) { + .plaintext => { + try writer.writeAll(" "); + try writer.writeAll(line); + try writer.writeAll("\n"); + }, + .markdown => { + try writer.writeAll(line); + try writer.writeAll("\n"); + }, + } + } + + fn header(self: Format) ?[]const u8 { + return switch (self) { + .plaintext => null, + .markdown => + \\--- + \\title: Keybinding Action Reference + \\description: Reference of all Ghostty keybinding actions. + \\editOnGithubLink: https://github.com/ghostty-org/ghostty/edit/main/src/input/Binding.zig + \\--- + \\ + \\This is a reference of all Ghostty keybinding actions. + \\ + \\ + , + }; + } +}; + +/// Generate keybind actions documentation with the specified format +pub fn generate( + writer: *std.Io.Writer, + format: Format, + show_docs: bool, + page_allocator: std.mem.Allocator, +) !void { + if (format.header()) |header| { + try writer.writeAll(header); + } + + var stream: std.Io.Writer.Allocating = .init(page_allocator); + defer stream.deinit(); + + const fields = @typeInfo(KeybindAction).@"union".fields; + inline for (fields) |field| { + if (field.name[0] == '_') continue; + + // Write previously stored doc comment below all related actions + if (show_docs and @hasDecl(help_strings.KeybindAction, field.name)) { + try writer.writeAll(stream.written()); + try writer.writeAll("\n"); + stream.clearRetainingCapacity(); + } + + if (show_docs) { + try format.formatFieldName(writer, field.name); + } else { + try writer.writeAll(field.name); + try writer.writeAll("\n"); + } + + if (show_docs and @hasDecl(help_strings.KeybindAction, field.name)) { + var iter = std.mem.splitScalar( + u8, + @field(help_strings.KeybindAction, field.name), + '\n', + ); + while (iter.next()) |s| { + // If it is the last line and empty, then skip it. + if (iter.peek() == null and s.len == 0) continue; + try format.formatDocLine(&stream.writer, s); + } + } + } + + // Write any remaining buffered documentation + if (stream.written().len > 0) { + try writer.writeAll(stream.written()); + } +} diff --git a/vendor/libghostty-vt/src/input/key.zig b/vendor/libghostty-vt/src/input/key.zig new file mode 100644 index 00000000..9c04f01e --- /dev/null +++ b/vendor/libghostty-vt/src/input/key.zig @@ -0,0 +1,878 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); + +pub const Mods = @import("key_mods.zig").Mods; + +/// A generic key input event. This is the information that is necessary +/// regardless of apprt in order to generate the proper terminal +/// control sequences for a given key press. +/// +/// Some apprts may not be able to provide all of this information, such +/// as GLFW. In this case, the apprt should provide as much information +/// as it can and it should be expected that the terminal behavior +/// will not be totally correct. +pub const KeyEvent = struct { + /// The action: press, release, etc. + action: Action = .press, + + /// The keycode of the physical key that was pressed. This is agnostic + /// to the layout. Layout-dependent matching can only be done via the + /// UTF-8 or unshifted codepoint. + key: Key = .unidentified, + + /// Mods are the modifiers that are pressed. + mods: Mods = .{}, + + /// The mods that were consumed in order to generate the text + /// in utf8. This has the mods set that were consumed, so to + /// get the set of mods that are effective you must negate + /// mods with this. + /// + /// This field is meaningless if utf8 is empty. + consumed_mods: Mods = .{}, + + /// Composing is true when this key event is part of a dead key + /// composition sequence and we're in the middle of it. + composing: bool = false, + + /// The utf8 sequence that was generated by this key event. + /// This will be an empty string if there is no text generated. + /// If composing is true and this is non-empty, this is preedit + /// text. + utf8: []const u8 = "", + + /// The codepoint for this key when it is unshifted. For example, + /// shift+a is "A" in UTF-8 but unshifted would provide 'a'. + unshifted_codepoint: u21 = 0, + + /// Returns the effective modifiers for this event. The effective + /// modifiers are the mods that should be considered for keybindings. + pub fn effectiveMods(self: KeyEvent) Mods { + if (self.utf8.len == 0) return self.mods; + return self.mods.unset(self.consumed_mods); + } + + /// Returns a unique hash for this key event to be used for tracking + /// uniquess specifically with bindings. This omits fields that are + /// irrelevant for bindings. + pub fn bindingHash(self: KeyEvent) u64 { + var hasher = std.hash.Wyhash.init(0); + + // These are all the fields that are explicitly part of Trigger. + std.hash.autoHash(&hasher, self.key); + std.hash.autoHash(&hasher, self.unshifted_codepoint); + std.hash.autoHash(&hasher, self.mods.binding()); + + // Notes on unmapped things and why: + // + // - action: we don't have action-specific bindings right now + // AND we want to know if a key resulted in a binding regardless + // of action because a press should also ignore a release and so on. + // + // We can add to this if there is other confusion. + + return hasher.final(); + } +}; + +/// The action associated with an input event. This is backed by a c_int +/// so that we can use the enum as-is for our embedding API. +/// +/// IMPORTANT: Any changes here update include/ghostty.h +pub const Action = enum(c_int) { + release, + press, + repeat, +}; + +/// The set of key codes that Ghostty is aware of. These represent +/// physical keys on the keyboard. The logical key (or key string) +/// is the string that is generated by the key event and that is up +/// to the apprt to provide. +/// +/// Note that these are layout-independent. For example, the "a" +/// key on a US keyboard is the same as the "ф" key on a Russian +/// keyboard, but both will report the "a" enum value in the key +/// event. These values are based on the W3C standard. See: +/// https://www.w3.org/TR/uievents-code +/// +/// Layout-dependent strings are provided in the KeyEvent struct as +/// UTF-8 and are produced by the associated apprt. Ghostty core has +/// no mechanism to map input events to strings without the apprt. +/// +/// IMPORTANT: Any changes here update include/ghostty.h ghostty_input_key_e +pub const Key = enum(c_int) { + unidentified, + + // "Writing System Keys" § 3.1.1 + backquote, + backslash, + bracket_left, + bracket_right, + comma, + digit_0, + digit_1, + digit_2, + digit_3, + digit_4, + digit_5, + digit_6, + digit_7, + digit_8, + digit_9, + equal, + intl_backslash, + intl_ro, + intl_yen, + key_a, + key_b, + key_c, + key_d, + key_e, + key_f, + key_g, + key_h, + key_i, + key_j, + key_k, + key_l, + key_m, + key_n, + key_o, + key_p, + key_q, + key_r, + key_s, + key_t, + key_u, + key_v, + key_w, + key_x, + key_y, + key_z, + minus, + period, + quote, + semicolon, + slash, + + // "Functional Keys" § 3.1.2 + alt_left, + alt_right, + backspace, + caps_lock, + context_menu, + control_left, + control_right, + enter, + meta_left, + meta_right, + shift_left, + shift_right, + space, + tab, + convert, + kana_mode, + non_convert, + + // "Control Pad Section" § 3.2 + delete, + end, + help, + home, + insert, + page_down, + page_up, + + // "Arrow Pad Section" § 3.3 + arrow_down, + arrow_left, + arrow_right, + arrow_up, + + // "Numpad Section" § 3.4 + num_lock, + numpad_0, + numpad_1, + numpad_2, + numpad_3, + numpad_4, + numpad_5, + numpad_6, + numpad_7, + numpad_8, + numpad_9, + numpad_add, + numpad_backspace, + numpad_clear, + numpad_clear_entry, + numpad_comma, + numpad_decimal, + numpad_divide, + numpad_enter, + numpad_equal, + numpad_memory_add, + numpad_memory_clear, + numpad_memory_recall, + numpad_memory_store, + numpad_memory_subtract, + numpad_multiply, + numpad_paren_left, + numpad_paren_right, + numpad_subtract, + + // > For numpads that provide keys not listed here, a code value string + // > should be created by starting with "Numpad" and appending an + // > appropriate description of the key. + // + // These numpad entries are distinguished by various encoding protocols + // (legacy and Kitty) so we support them here in case the apprt can + // produce them. + numpad_separator, + numpad_up, + numpad_down, + numpad_right, + numpad_left, + numpad_begin, + numpad_home, + numpad_end, + numpad_insert, + numpad_delete, + numpad_page_up, + numpad_page_down, + + // "Function Section" § 3.5 + escape, + f1, + f2, + f3, + f4, + f5, + f6, + f7, + f8, + f9, + f10, + f11, + f12, + f13, + f14, + f15, + f16, + f17, + f18, + f19, + f20, + f21, + f22, + f23, + f24, + f25, + @"fn", + fn_lock, + print_screen, + scroll_lock, + pause, + + // "Media Keys" § 3.6 + browser_back, + browser_favorites, + browser_forward, + browser_home, + browser_refresh, + browser_search, + browser_stop, + eject, + launch_app_1, + launch_app_2, + launch_mail, + media_play_pause, + media_select, + media_stop, + media_track_next, + media_track_previous, + power, + sleep, + audio_volume_down, + audio_volume_mute, + audio_volume_up, + wake_up, + + // "Legacy, Non-standard, and Special Keys" § 3.7 + copy, + cut, + paste, + + /// Converts an ASCII character to a key, if possible. This returns + /// null if the character is unknown. + /// + /// Note that this can't distinguish between physical keys, i.e. '0' + /// may be from the number row or the keypad, but it always maps + /// to '.zero'. + /// + /// This is what we want, we want people to create keybindings that + /// are independent of the physical key. + pub fn fromASCII(ch: u8) ?Key { + return switch (ch) { + inline else => |comptime_ch| { + return comptime result: { + @setEvalBranchQuota(100_000); + for (codepoint_map) |entry| { + // No ASCII characters should ever map to a keypad key + if (entry[1].keypad()) continue; + + if (entry[0] == @as(u21, @intCast(comptime_ch))) { + break :result entry[1]; + } + } + + break :result null; + }; + }, + }; + } + + /// Converts a W3C key code to a Ghostty key enum value. + /// + /// All required W3C key codes are supported, but there are a number of + /// non-standard key codes that are not supported. In the case the value is + /// invalid or unsupported, this function will return null. + pub fn fromW3C(code: []const u8) ?Key { + var result: [128]u8 = undefined; + + // If the code is bigger than our buffer it can't possibly match. + if (code.len > result.len) return null; + + // First just check the whole thing lowercased, this is the simple case + if (std.meta.stringToEnum( + Key, + std.ascii.lowerString(&result, code), + )) |key| return key; + + // We need to convert FooBar to foo_bar + var fbs = std.io.fixedBufferStream(&result); + const w = fbs.writer(); + for (code, 0..) |ch, i| switch (ch) { + 'a'...'z' => w.writeByte(ch) catch return null, + + // Caps and numbers trigger underscores + 'A'...'Z', '0'...'9' => { + if (i > 0) w.writeByte('_') catch return null; + w.writeByte(std.ascii.toLower(ch)) catch return null; + }, + + // We don't know of any key codes that aren't alphanumeric. + else => return null, + }; + + return std.meta.stringToEnum(Key, fbs.getWritten()); + } + + /// Converts a Ghostty key enum value to a W3C key code. + pub fn w3c(self: Key) []const u8 { + return switch (self) { + inline else => |tag| comptime w3c: { + @setEvalBranchQuota(50_000); + + const name = @tagName(tag); + + var buf: [128]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const w = fbs.writer(); + var i: usize = 0; + while (i < name.len) { + if (i == 0) { + w.writeByte(std.ascii.toUpper(name[i])) catch unreachable; + } else if (name[i] == '_') { + i += 1; + w.writeByte(std.ascii.toUpper(name[i])) catch unreachable; + } else { + w.writeByte(name[i]) catch unreachable; + } + + i += 1; + } + + const written = buf; + const result = written[0..fbs.getWritten().len]; + break :w3c result; + }, + }; + } + + /// True if this key represents a printable character. + pub fn printable(self: Key) bool { + return switch (self) { + inline else => |tag| { + return comptime result: { + @setEvalBranchQuota(10_000); + for (codepoint_map) |entry| { + if (entry[1] == tag) break :result true; + } + + break :result false; + }; + }, + }; + } + + /// True if this key is a modifier. + pub fn modifier(self: Key) bool { + return switch (self) { + .shift_left, + .control_left, + .alt_left, + .meta_left, + .shift_right, + .control_right, + .alt_right, + .meta_right, + => true, + + else => false, + }; + } + + /// Whether this key should be remappable by the operating system. + /// + /// On certain OSes (namely Linux and the BSDs) certain keys like the + /// functional keys are expected to be remappable by the user, such as + /// in the very common use case of swapping the Caps Lock key with the + /// Escape key with the XKB option `caps:swapescape`. + /// + /// However, the way XKB implements this is by essentially acting as a + /// software key remapper that destroys all information about the original + /// physical key, leading to very annoying bugs like #7309 where the + /// physical key `XKB_KEY_c` gets remapped into `XKB_KEY_Cyrillic_tse`, + /// which causes all of our physical key handling to completely break down. + /// _Very naughty._ + /// + /// As a compromise, given that writing system keys (§3.1.1) comprise the + /// majority of keys that "change meaning [...] based on the current locale + /// and keyboard layout", we allow all other keys to be remapped by default + /// since they should be fairly harmless. We might consider making this + /// configurable, but for now this should at least placate most people. + pub fn shouldBeRemappable(self: Key) bool { + return switch (self) { + // "Writing System Keys" § 3.1.1 + .backquote, + .backslash, + .bracket_left, + .bracket_right, + .comma, + .digit_0, + .digit_1, + .digit_2, + .digit_3, + .digit_4, + .digit_5, + .digit_6, + .digit_7, + .digit_8, + .digit_9, + .equal, + .intl_backslash, + .intl_ro, + .intl_yen, + .key_a, + .key_b, + .key_c, + .key_d, + .key_e, + .key_f, + .key_g, + .key_h, + .key_i, + .key_j, + .key_k, + .key_l, + .key_m, + .key_n, + .key_o, + .key_p, + .key_q, + .key_r, + .key_s, + .key_t, + .key_u, + .key_v, + .key_w, + .key_x, + .key_y, + .key_z, + .minus, + .period, + .quote, + .semicolon, + .slash, + => false, + + else => true, + }; + } + + /// Returns true if this is a keypad key. + pub fn keypad(self: Key) bool { + return switch (self) { + inline else => |tag| { + const name = @tagName(tag); + const result = comptime std.mem.startsWith(u8, name, "numpad_"); + return result; + }, + }; + } + + // Returns the codepoint representing this key, or null if the key is not + // printable + pub fn codepoint(self: Key) ?u21 { + return switch (self) { + inline else => |tag| { + return comptime result: { + @setEvalBranchQuota(10_000); + for (codepoint_map) |entry| { + if (entry[1] == tag) break :result entry[0]; + } + + break :result null; + }; + }, + }; + } + + /// Returns the cimgui key constant for this key. + pub fn imguiKey(self: Key) ?c_int { + return switch (self) { + .key_a => cimgui.c.ImGuiKey_A, + .key_b => cimgui.c.ImGuiKey_B, + .key_c => cimgui.c.ImGuiKey_C, + .key_d => cimgui.c.ImGuiKey_D, + .key_e => cimgui.c.ImGuiKey_E, + .key_f => cimgui.c.ImGuiKey_F, + .key_g => cimgui.c.ImGuiKey_G, + .key_h => cimgui.c.ImGuiKey_H, + .key_i => cimgui.c.ImGuiKey_I, + .key_j => cimgui.c.ImGuiKey_J, + .key_k => cimgui.c.ImGuiKey_K, + .key_l => cimgui.c.ImGuiKey_L, + .key_m => cimgui.c.ImGuiKey_M, + .key_n => cimgui.c.ImGuiKey_N, + .key_o => cimgui.c.ImGuiKey_O, + .key_p => cimgui.c.ImGuiKey_P, + .key_q => cimgui.c.ImGuiKey_Q, + .key_r => cimgui.c.ImGuiKey_R, + .key_s => cimgui.c.ImGuiKey_S, + .key_t => cimgui.c.ImGuiKey_T, + .key_u => cimgui.c.ImGuiKey_U, + .key_v => cimgui.c.ImGuiKey_V, + .key_w => cimgui.c.ImGuiKey_W, + .key_x => cimgui.c.ImGuiKey_X, + .key_y => cimgui.c.ImGuiKey_Y, + .key_z => cimgui.c.ImGuiKey_Z, + + .digit_0 => cimgui.c.ImGuiKey_0, + .digit_1 => cimgui.c.ImGuiKey_1, + .digit_2 => cimgui.c.ImGuiKey_2, + .digit_3 => cimgui.c.ImGuiKey_3, + .digit_4 => cimgui.c.ImGuiKey_4, + .digit_5 => cimgui.c.ImGuiKey_5, + .digit_6 => cimgui.c.ImGuiKey_6, + .digit_7 => cimgui.c.ImGuiKey_7, + .digit_8 => cimgui.c.ImGuiKey_8, + .digit_9 => cimgui.c.ImGuiKey_9, + + .semicolon => cimgui.c.ImGuiKey_Semicolon, + .space => cimgui.c.ImGuiKey_Space, + .quote => cimgui.c.ImGuiKey_Apostrophe, + .comma => cimgui.c.ImGuiKey_Comma, + .backquote => cimgui.c.ImGuiKey_GraveAccent, + .period => cimgui.c.ImGuiKey_Period, + .slash => cimgui.c.ImGuiKey_Slash, + .minus => cimgui.c.ImGuiKey_Minus, + .equal => cimgui.c.ImGuiKey_Equal, + .bracket_left => cimgui.c.ImGuiKey_LeftBracket, + .bracket_right => cimgui.c.ImGuiKey_RightBracket, + .backslash => cimgui.c.ImGuiKey_Backslash, + + .arrow_up => cimgui.c.ImGuiKey_UpArrow, + .arrow_down => cimgui.c.ImGuiKey_DownArrow, + .arrow_left => cimgui.c.ImGuiKey_LeftArrow, + .arrow_right => cimgui.c.ImGuiKey_RightArrow, + .home => cimgui.c.ImGuiKey_Home, + .end => cimgui.c.ImGuiKey_End, + .insert => cimgui.c.ImGuiKey_Insert, + .delete => cimgui.c.ImGuiKey_Delete, + .caps_lock => cimgui.c.ImGuiKey_CapsLock, + .scroll_lock => cimgui.c.ImGuiKey_ScrollLock, + .num_lock => cimgui.c.ImGuiKey_NumLock, + .page_up => cimgui.c.ImGuiKey_PageUp, + .page_down => cimgui.c.ImGuiKey_PageDown, + .escape => cimgui.c.ImGuiKey_Escape, + .enter => cimgui.c.ImGuiKey_Enter, + .tab => cimgui.c.ImGuiKey_Tab, + .backspace => cimgui.c.ImGuiKey_Backspace, + .print_screen => cimgui.c.ImGuiKey_PrintScreen, + .pause => cimgui.c.ImGuiKey_Pause, + .context_menu => cimgui.c.ImGuiKey_Menu, + + .f1 => cimgui.c.ImGuiKey_F1, + .f2 => cimgui.c.ImGuiKey_F2, + .f3 => cimgui.c.ImGuiKey_F3, + .f4 => cimgui.c.ImGuiKey_F4, + .f5 => cimgui.c.ImGuiKey_F5, + .f6 => cimgui.c.ImGuiKey_F6, + .f7 => cimgui.c.ImGuiKey_F7, + .f8 => cimgui.c.ImGuiKey_F8, + .f9 => cimgui.c.ImGuiKey_F9, + .f10 => cimgui.c.ImGuiKey_F10, + .f11 => cimgui.c.ImGuiKey_F11, + .f12 => cimgui.c.ImGuiKey_F12, + + .numpad_0 => cimgui.c.ImGuiKey_Keypad0, + .numpad_1 => cimgui.c.ImGuiKey_Keypad1, + .numpad_2 => cimgui.c.ImGuiKey_Keypad2, + .numpad_3 => cimgui.c.ImGuiKey_Keypad3, + .numpad_4 => cimgui.c.ImGuiKey_Keypad4, + .numpad_5 => cimgui.c.ImGuiKey_Keypad5, + .numpad_6 => cimgui.c.ImGuiKey_Keypad6, + .numpad_7 => cimgui.c.ImGuiKey_Keypad7, + .numpad_8 => cimgui.c.ImGuiKey_Keypad8, + .numpad_9 => cimgui.c.ImGuiKey_Keypad9, + .numpad_decimal => cimgui.c.ImGuiKey_KeypadDecimal, + .numpad_divide => cimgui.c.ImGuiKey_KeypadDivide, + .numpad_multiply => cimgui.c.ImGuiKey_KeypadMultiply, + .numpad_subtract => cimgui.c.ImGuiKey_KeypadSubtract, + .numpad_add => cimgui.c.ImGuiKey_KeypadAdd, + .numpad_enter => cimgui.c.ImGuiKey_KeypadEnter, + .numpad_equal => cimgui.c.ImGuiKey_KeypadEqual, + // We map KP_SEPARATOR to Comma because traditionally a numpad would + // have a numeric separator key. Most modern numpads do not + .numpad_left => cimgui.c.ImGuiKey_LeftArrow, + .numpad_right => cimgui.c.ImGuiKey_RightArrow, + .numpad_up => cimgui.c.ImGuiKey_UpArrow, + .numpad_down => cimgui.c.ImGuiKey_DownArrow, + .numpad_page_up => cimgui.c.ImGuiKey_PageUp, + .numpad_page_down => cimgui.c.ImGuiKey_PageUp, + .numpad_home => cimgui.c.ImGuiKey_Home, + .numpad_end => cimgui.c.ImGuiKey_End, + .numpad_insert => cimgui.c.ImGuiKey_Insert, + .numpad_delete => cimgui.c.ImGuiKey_Delete, + .numpad_begin => cimgui.c.ImGuiKey_NamedKey_BEGIN, + + .shift_left => cimgui.c.ImGuiKey_LeftShift, + .control_left => cimgui.c.ImGuiKey_LeftCtrl, + .alt_left => cimgui.c.ImGuiKey_LeftAlt, + .meta_left => cimgui.c.ImGuiKey_LeftSuper, + .shift_right => cimgui.c.ImGuiKey_RightShift, + .control_right => cimgui.c.ImGuiKey_RightCtrl, + .alt_right => cimgui.c.ImGuiKey_RightAlt, + .meta_right => cimgui.c.ImGuiKey_RightSuper, + + // These keys aren't represented in cimgui + .f13, + .f14, + .f15, + .f16, + .f17, + .f18, + .f19, + .f20, + .f21, + .f22, + .f23, + .f24, + .f25, + .intl_backslash, + .intl_ro, + .intl_yen, + .convert, + .kana_mode, + .non_convert, + .numpad_separator, + .numpad_backspace, + .numpad_clear, + .numpad_clear_entry, + .numpad_comma, + .numpad_memory_add, + .numpad_memory_clear, + .numpad_memory_recall, + .numpad_memory_store, + .numpad_memory_subtract, + .numpad_paren_left, + .numpad_paren_right, + .@"fn", + .fn_lock, + .browser_back, + .browser_favorites, + .browser_forward, + .browser_home, + .browser_refresh, + .browser_search, + .browser_stop, + .eject, + .launch_app_1, + .launch_app_2, + .launch_mail, + .media_play_pause, + .media_select, + .media_stop, + .media_track_next, + .media_track_previous, + .power, + .sleep, + .audio_volume_down, + .audio_volume_mute, + .audio_volume_up, + .wake_up, + .help, + .copy, + .cut, + .paste, + => null, + + .unidentified, + => null, + }; + } + + /// true if this key is one of the left or right versions of super (MacOS) + /// or ctrl. + pub fn ctrlOrSuper(self: Key) bool { + if (comptime builtin.target.os.tag.isDarwin()) { + return self == .meta_left or self == .meta_right; + } + return self == .control_left or self == .control_right; + } + + /// true if this key is either left or right shift. + pub fn leftOrRightShift(self: Key) bool { + return self == .shift_left or self == .shift_right; + } + + /// true if this key is either left or right alt. + pub fn leftOrRightAlt(self: Key) bool { + return self == .alt_left or self == .alt_right; + } + + test "fromASCII should not return keypad keys" { + const testing = std.testing; + try testing.expect(Key.fromASCII('0').? == .digit_0); + try testing.expect(Key.fromASCII('*') == null); + } + + test "keypad keys" { + const testing = std.testing; + try testing.expect(Key.numpad_0.keypad()); + try testing.expect(!Key.digit_1.keypad()); + } + + test "w3c" { + // All our keys should convert to and from the W3C format. + // We don't support every key in the W3C spec, so we only + // check the enum fields. + const testing = std.testing; + inline for (@typeInfo(Key).@"enum".fields) |field| { + const key = @field(Key, field.name); + const w3c_name = key.w3c(); + try testing.expectEqual(key, Key.fromW3C(w3c_name).?); + } + } + + const codepoint_map: []const struct { u21, Key } = &.{ + .{ 'a', .key_a }, + .{ 'b', .key_b }, + .{ 'c', .key_c }, + .{ 'd', .key_d }, + .{ 'e', .key_e }, + .{ 'f', .key_f }, + .{ 'g', .key_g }, + .{ 'h', .key_h }, + .{ 'i', .key_i }, + .{ 'j', .key_j }, + .{ 'k', .key_k }, + .{ 'l', .key_l }, + .{ 'm', .key_m }, + .{ 'n', .key_n }, + .{ 'o', .key_o }, + .{ 'p', .key_p }, + .{ 'q', .key_q }, + .{ 'r', .key_r }, + .{ 's', .key_s }, + .{ 't', .key_t }, + .{ 'u', .key_u }, + .{ 'v', .key_v }, + .{ 'w', .key_w }, + .{ 'x', .key_x }, + .{ 'y', .key_y }, + .{ 'z', .key_z }, + .{ '0', .digit_0 }, + .{ '1', .digit_1 }, + .{ '2', .digit_2 }, + .{ '3', .digit_3 }, + .{ '4', .digit_4 }, + .{ '5', .digit_5 }, + .{ '6', .digit_6 }, + .{ '7', .digit_7 }, + .{ '8', .digit_8 }, + .{ '9', .digit_9 }, + .{ ';', .semicolon }, + .{ ' ', .space }, + .{ '\'', .quote }, + .{ ',', .comma }, + .{ '`', .backquote }, + .{ '.', .period }, + .{ '/', .slash }, + .{ '-', .minus }, + .{ '=', .equal }, + .{ '[', .bracket_left }, + .{ ']', .bracket_right }, + .{ '\\', .backslash }, + + // Control characters + .{ '\t', .tab }, + + // Keypad entries. We just assume keypad with the numpad_ prefix + // so that has some special meaning. These must also always be last, + // so that our `fromASCII` function doesn't accidentally map them + // over normal numerics and other keys. + .{ '0', .numpad_0 }, + .{ '1', .numpad_1 }, + .{ '2', .numpad_2 }, + .{ '3', .numpad_3 }, + .{ '4', .numpad_4 }, + .{ '5', .numpad_5 }, + .{ '6', .numpad_6 }, + .{ '7', .numpad_7 }, + .{ '8', .numpad_8 }, + .{ '9', .numpad_9 }, + .{ '.', .numpad_decimal }, + .{ '/', .numpad_divide }, + .{ '*', .numpad_multiply }, + .{ '-', .numpad_subtract }, + .{ '+', .numpad_add }, + .{ '=', .numpad_equal }, + }; +}; + +/// This sets either "ctrl" or "super" to true (but not both) +/// on mods depending on if the build target is Mac or not. On +/// Mac, we default to super (i.e. super+c for copy) and on +/// non-Mac we default to ctrl (i.e. ctrl+c for copy). +pub fn ctrlOrSuper(mods: Mods) Mods { + var copy = mods; + if (comptime builtin.target.os.tag.isDarwin()) { + copy.super = true; + } else { + copy.ctrl = true; + } + + return copy; +} + +test "ctrlOrSuper" { + const testing = std.testing; + var m: Mods = ctrlOrSuper(.{}); + + try testing.expect(m.ctrlOrSuper()); +} 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.?); +} diff --git a/vendor/libghostty-vt/src/input/key_mods.zig b/vendor/libghostty-vt/src/input/key_mods.zig new file mode 100644 index 00000000..35e1c103 --- /dev/null +++ b/vendor/libghostty-vt/src/input/key_mods.zig @@ -0,0 +1,914 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); +const OptionAsAlt = @import("config.zig").OptionAsAlt; + +/// Aliases for modifier names. +pub const alias: []const struct { []const u8, Mod } = &.{ + .{ "cmd", .super }, + .{ "command", .super }, + .{ "opt", .alt }, + .{ "option", .alt }, + .{ "control", .ctrl }, +}; + +/// Single modifier +pub const Mod = enum { + shift, + ctrl, + alt, + super, + + pub const Side = enum(u1) { left, right }; +}; + +/// A bitmask for all key modifiers. +/// +/// IMPORTANT: Any changes here update include/ghostty.h +pub const Mods = packed struct(Mods.Backing) { + pub const Backing = u16; + + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + caps_lock: bool = false, + num_lock: bool = false, + sides: Side = .{}, + _padding: u6 = 0, + + /// The standard modifier keys only. Does not include the lock keys, + /// only standard bindable keys. + pub const Keys = packed struct(u4) { + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + + pub const Backing = @typeInfo(Keys).@"struct".backing_integer.?; + + pub inline fn int(self: Keys) Keys.Backing { + return @bitCast(self); + } + }; + + /// Tracks the side that is active for any given modifier. Note + /// that this doesn't confirm a modifier is pressed; you must check + /// the bool for that in addition to this. + /// + /// Not all platforms support this, check apprt for more info. + pub const Side = packed struct(u4) { + shift: Mod.Side = .left, + ctrl: Mod.Side = .left, + alt: Mod.Side = .left, + super: Mod.Side = .left, + + pub const Backing = @typeInfo(Side).@"struct".backing_integer.?; + }; + + /// The mask that has all the side bits set. + pub const side_mask: Mods = .{ + .sides = .{ + .shift = .right, + .ctrl = .right, + .alt = .right, + .super = .right, + }, + }; + + /// Integer value of this struct. + pub fn int(self: Mods) Backing { + return @bitCast(self); + } + + /// Returns true if no modifiers are set. + pub fn empty(self: Mods) bool { + return self.int() == 0; + } + + /// Returns true if two mods are equal. + pub fn equal(self: Mods, other: Mods) bool { + return self.int() == other.int(); + } + + /// Returns only the keys. + /// + /// In the future I want to remove `binding` for this. I didn't want + /// to do that all in one PR where I added this because its a bigger + /// change. + pub fn keys(self: Mods) Keys { + const backing: Keys.Backing = @truncate(self.int()); + return @bitCast(backing); + } + + /// Return mods that are only relevant for bindings. + pub fn binding(self: Mods) Mods { + return .{ + .shift = self.shift, + .ctrl = self.ctrl, + .alt = self.alt, + .super = self.super, + }; + } + + /// Perform `self &~ other` to remove the other mods from self. + pub fn unset(self: Mods, other: Mods) Mods { + return @bitCast(self.int() & ~other.int()); + } + + /// Returns the mods without locks set. + pub fn withoutLocks(self: Mods) Mods { + var copy = self; + copy.caps_lock = false; + copy.num_lock = false; + return copy; + } + + /// Return the mods to use for key translation. This handles settings + /// like macos-option-as-alt. The translation mods should be used for + /// translation but never sent back in for the key callback. + pub fn translation(self: Mods, option_as_alt: OptionAsAlt) Mods { + var result = self; + + // macos-option-as-alt for darwin + if (comptime builtin.target.os.tag.isDarwin()) alt: { + // Alt has to be set only on the correct side + switch (option_as_alt) { + .false => break :alt, + .true => {}, + .left => if (self.sides.alt == .right) break :alt, + .right => if (self.sides.alt == .left) break :alt, + } + + // Unset alt + result.alt = false; + } + + return result; + } + + /// Checks to see if super is on (MacOS) or ctrl. + pub fn ctrlOrSuper(self: Mods) bool { + if (comptime builtin.target.os.tag.isDarwin()) { + return self.super; + } + return self.ctrl; + } + + // For our own understanding + test { + const testing = std.testing; + try testing.expectEqual(@as(Backing, @bitCast(Mods{})), @as(Backing, 0b0)); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .shift = true })), + @as(Backing, 0b0000_0001), + ); + } + + test "translation macos-option-as-alt" { + if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + + const testing = std.testing; + + // Unset + { + const mods: Mods = .{}; + const result = mods.translation(.true); + try testing.expectEqual(result, mods); + } + + // Set + { + const mods: Mods = .{ .alt = true }; + const result = mods.translation(.true); + try testing.expectEqual(Mods{}, result); + } + + // Set but disabled + { + const mods: Mods = .{ .alt = true }; + const result = mods.translation(.false); + try testing.expectEqual(result, mods); + } + + // Set wrong side + { + const mods: Mods = .{ .alt = true, .sides = .{ .alt = .right } }; + const result = mods.translation(.left); + try testing.expectEqual(result, mods); + } + { + const mods: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const result = mods.translation(.right); + try testing.expectEqual(result, mods); + } + + // Set with other mods + { + const mods: Mods = .{ .alt = true, .shift = true }; + const result = mods.translation(.true); + try testing.expectEqual(Mods{ .shift = true }, result); + } + } +}; + +/// Modifier remapping. See `key-remap` in Config.zig for detailed docs. +pub const RemapSet = struct { + /// Available mappings. + map: std.AutoArrayHashMapUnmanaged(Mods, Mods), + + /// The mask of remapped modifiers that can be used to quickly + /// check if some input mods need remapping. + mask: Mask, + + pub const empty: RemapSet = .{ + .map = .{}, + .mask = .{}, + }; + + pub const ParseError = Allocator.Error || error{ + MissingAssignment, + InvalidMod, + }; + + /// Parse from CLI input. Required by Config. + pub fn parseCLI(self: *RemapSet, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse ""; + + // Empty value resets the set + if (value.len == 0) { + self.map.clearRetainingCapacity(); + self.mask = .{}; + return; + } + + self.parse(alloc, value) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.MissingAssignment, error.InvalidMod => return error.InvalidValue, + }; + } + + /// Parse a modifier remap and add it to the set. + pub fn parse( + self: *RemapSet, + alloc: Allocator, + input: []const u8, + ) ParseError!void { + // Find the assignment point ('=') + const eql_idx = std.mem.indexOfScalar( + u8, + input, + '=', + ) orelse return error.MissingAssignment; + + // The to side defaults to "left" if no explicit side is given. + // This is because this is the default unsided value provided by + // the apprts in the current Mods layout. + const to: Mods = to: { + const raw = try parseMod(input[eql_idx + 1 ..]); + break :to initMods(raw[0], raw[1] orelse .left); + }; + + // The from side, if sided, is easy and we put it directly into + // the map. + const from_raw = try parseMod(input[0..eql_idx]); + if (from_raw[1]) |from_side| { + const from: Mods = initMods(from_raw[0], from_side); + try self.map.put( + alloc, + from, + to, + ); + errdefer comptime unreachable; + self.mask.update(from); + return; + } + + // We need to do some combinatorial explosion here for unsided + // from in order to assign all possible sides. + const from_left = initMods(from_raw[0], .left); + const from_right = initMods(from_raw[0], .right); + try self.map.put( + alloc, + from_left, + to, + ); + errdefer _ = self.map.swapRemove(from_left); + try self.map.put( + alloc, + from_right, + to, + ); + errdefer _ = self.map.swapRemove(from_right); + + errdefer comptime unreachable; + self.mask.update(from_left); + self.mask.update(from_right); + } + + pub fn deinit(self: *RemapSet, alloc: Allocator) void { + self.map.deinit(alloc); + } + + /// Must be called prior to any remappings so that the mapping + /// is sorted properly. Otherwise, you will get invalid results. + pub fn finalize(self: *RemapSet) void { + const Context = struct { + keys: []const Mods, + + pub fn lessThan( + ctx: @This(), + a_index: usize, + b_index: usize, + ) bool { + _ = b_index; + + // Mods with any right sides prioritize + const side_mask = comptime Mods.side_mask.int(); + const a = ctx.keys[a_index]; + return a.int() & side_mask != 0; + } + }; + + self.map.sort(Context{ .keys = self.map.keys() }); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const RemapSet, alloc: Allocator) Allocator.Error!RemapSet { + return .{ + .map = try self.map.clone(alloc), + .mask = self.mask, + }; + } + + /// Compare if two RemapSets are equal. Required by Config. + pub fn equal(self: RemapSet, other: RemapSet) bool { + if (self.map.count() != other.map.count()) return false; + + var it = self.map.iterator(); + while (it.next()) |entry| { + const other_value = other.map.get(entry.key_ptr.*) orelse return false; + if (!entry.value_ptr.equal(other_value)) return false; + } + + return true; + } + + /// Used by Formatter. Required by Config. + pub fn formatEntry(self: RemapSet, formatter: anytype) !void { + if (self.map.count() == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var it = self.map.iterator(); + while (it.next()) |entry| { + const from = entry.key_ptr.*; + const to = entry.value_ptr.*; + + var buf: [64]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + formatMod(writer, from) catch return error.OutOfMemory; + writer.writeByte('=') catch return error.OutOfMemory; + formatMod(writer, to) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, fbs.getWritten()); + } + } + + fn formatMod(writer: anytype, mods: Mods) !void { + // Check which mod is set and format it with optional side prefix + inline for (.{ "shift", "ctrl", "alt", "super" }) |name| { + if (@field(mods, name)) { + const side = @field(mods.sides, name); + if (side == .right) { + try writer.writeAll("right_"); + } else { + // Only write left_ if we need to distinguish + // For now, always write left_ if it's a sided mapping + try writer.writeAll("left_"); + } + try writer.writeAll(name); + return; + } + } + } + + /// Parses a single mode in a single remapping string. E.g. + /// `ctrl` or `left_shift`. + fn parseMod(input: []const u8) error{InvalidMod}!struct { Mod, ?Mod.Side } { + const side_str, const mod_str = if (std.mem.indexOfScalar( + u8, + input, + '_', + )) |idx| .{ + input[0..idx], + input[idx + 1 ..], + } else .{ + "", + input, + }; + + const mod: Mod = if (std.meta.stringToEnum( + Mod, + mod_str, + )) |mod| mod else mod: { + inline for (alias) |pair| { + if (std.mem.eql(u8, mod_str, pair[0])) { + break :mod pair[1]; + } + } + + return error.InvalidMod; + }; + + return .{ + mod, + if (side_str.len > 0) std.meta.stringToEnum( + Mod.Side, + side_str, + ) orelse return error.InvalidMod else null, + }; + } + + fn initMods(mod: Mod, side: Mod.Side) Mods { + switch (mod) { + inline else => |tag| { + var mods: Mods = .{}; + @field(mods, @tagName(tag)) = true; + @field(mods.sides, @tagName(tag)) = side; + return mods; + }, + } + } + + /// Returns true if the given mods need remapping. + pub fn isRemapped(self: *const RemapSet, mods: Mods) bool { + return self.mask.match(mods); + } + + /// Apply a remap to the given mods. + pub fn apply(self: *const RemapSet, mods: Mods) Mods { + if (!self.isRemapped(mods)) return mods; + + const mods_binding: Mods.Keys.Backing = @truncate(mods.int()); + const mods_sides: Mods.Side.Backing = @bitCast(mods.sides); + + var it = self.map.iterator(); + while (it.next()) |entry| { + const from = entry.key_ptr.*; + const from_binding: Mods.Keys.Backing = @truncate(from.int()); + if (mods_binding & from_binding != from_binding) continue; + const from_sides: Mods.Side.Backing = @bitCast(from.sides); + if ((mods_sides ^ from_sides) & from_binding != 0) continue; + + var mods_int = mods.int(); + mods_int &= ~from.int(); + mods_int |= entry.value_ptr.int(); + return @bitCast(mods_int); + } + + unreachable; + } + + /// Tracks which modifier keys and sides have remappings registered. + /// Used as a fast pre-check before doing expensive map lookups. + /// + /// The mask uses separate tracking for left and right sides because + /// remappings can be side-specific (e.g., only remap left_ctrl). + /// + /// Note: `left_sides` uses inverted logic where 1 means "left is remapped" + /// even though `Mod.Side.left = 0`. This allows efficient bitwise matching + /// since we can AND directly with the side bits. + pub const Mask = packed struct(u12) { + /// Which modifier keys (shift/ctrl/alt/super) have any remapping. + keys: Mods.Keys = .{}, + /// Which modifiers have left-side remappings (inverted: 1 = left remapped). + left_sides: Mods.Side = .{}, + /// Which modifiers have right-side remappings (1 = right remapped). + right_sides: Mods.Side = .{}, + + /// Adds a modifier to the mask, marking it as having a remapping. + pub fn update(self: *Mask, mods: Mods) void { + const keys_int: Mods.Keys.Backing = mods.keys().int(); + + // OR the new keys into our existing keys mask. + // Example: keys=0b0000, new ctrl → keys=0b0010 + self.keys = @bitCast(self.keys.int() | keys_int); + + // Both Keys and Side are u4 with matching bit positions. + // This lets us use keys_int to select which side bits to update. + const sides: Mods.Side.Backing = @bitCast(mods.sides); + const left_int: Mods.Side.Backing = @bitCast(self.left_sides); + const right_int: Mods.Side.Backing = @bitCast(self.right_sides); + + // Update left_sides: set bit if this key is active AND side is left. + // Since Side.left=0, we invert sides (~sides) so left becomes 1. + // keys_int masks to only affect the modifier being added. + // Example: left_ctrl → keys_int=0b0010, ~sides=0b1111 (left=0 inverted) + // result: left_int | (0b0010 & 0b1111) = left_int | 0b0010 + self.left_sides = @bitCast(left_int | (keys_int & ~sides)); + + // Update right_sides: set bit if this key is active AND side is right. + // Since Side.right=1, we use sides directly. + // Example: right_ctrl → keys_int=0b0010, sides=0b0010 (right=1) + // result: right_int | (0b0010 & 0b0010) = right_int | 0b0010 + self.right_sides = @bitCast(right_int | (keys_int & sides)); + } + + /// Returns true if the given mods match any remapping in this mask. + /// This is a fast check to avoid expensive map lookups when no + /// remapping could possibly apply. + /// + /// Checks both that the modifier key is remapped AND that the + /// specific side (left/right) being pressed has a remapping. + pub fn match(self: *const Mask, mods: Mods) bool { + // Find which pressed keys have remappings registered. + // Example: pressed={ctrl,alt}, mask={ctrl} → active=0b0010 (just ctrl) + const active = mods.keys().int() & self.keys.int(); + if (active == 0) return false; + + // Check if the pressed side matches a remapped side. + // For left (sides bit = 0): check against left_int (where 1 = left remapped) + // ~sides inverts so left becomes 1, then AND with left_int + // For right (sides bit = 1): check against right_int directly + // + // Example: pressing left_ctrl (sides.ctrl=0, left_int.ctrl=1) + // ~sides = 0b1111, left_int = 0b0010 + // (~sides & left_int) = 0b0010 ✓ matches + // + // Example: pressing right_ctrl but only left_ctrl is remapped + // sides = 0b0010, left_int = 0b0010, right_int = 0b0000 + // (~0b0010 & 0b0010) | (0b0010 & 0b0000) = 0b0000 ✗ no match + const sides: Mods.Side.Backing = @bitCast(mods.sides); + const left_int: Mods.Side.Backing = @bitCast(self.left_sides); + const right_int: Mods.Side.Backing = @bitCast(self.right_sides); + const side_match = (~sides & left_int) | (sides & right_int); + + // Final check: is any active (pressed + remapped) key also side-matched? + return (active & side_match) != 0; + } + }; +}; + +test "RemapSet: unsided remap creates both left and right mappings" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + try set.parse(alloc, "ctrl=super"); + set.finalize(); + try testing.expectEqual( + Mods{ + .super = true, + .sides = .{ .super = .left }, + }, + set.apply(.{ + .ctrl = true, + .sides = .{ .ctrl = .left }, + }), + ); + try testing.expectEqual( + Mods{ + .super = true, + .sides = .{ .super = .left }, + }, + set.apply(.{ + .ctrl = true, + .sides = .{ .ctrl = .right }, + }), + ); +} + +test "RemapSet: sided from only maps that side" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_alt=ctrl"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl, set.apply(left_alt)); + + const right_alt: Mods = .{ .alt = true, .sides = .{ .alt = .right } }; + try testing.expectEqual(right_alt, set.apply(right_alt)); +} + +test "RemapSet: sided to" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=right_super"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const right_super: Mods = .{ .super = true, .sides = .{ .super = .right } }; + try testing.expectEqual(right_super, set.apply(left_ctrl)); +} + +test "RemapSet: both sides specified" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_shift=right_ctrl"); + set.finalize(); + + const left_shift: Mods = .{ .shift = true, .sides = .{ .shift = .left } }; + const right_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .right } }; + try testing.expectEqual(right_ctrl, set.apply(left_shift)); +} + +test "RemapSet: multiple parses accumulate" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_ctrl=super"); + try set.parse(alloc, "left_alt=ctrl"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_ctrl)); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_ctrl_result: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl_result, set.apply(left_alt)); +} + +test "RemapSet: error on missing assignment" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.MissingAssignment, set.parse(alloc, "ctrl")); + try testing.expectError(error.MissingAssignment, set.parse(alloc, "")); +} + +test "RemapSet: error on invalid modifier" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.InvalidMod, set.parse(alloc, "invalid=ctrl")); + try testing.expectError(error.InvalidMod, set.parse(alloc, "ctrl=invalid")); + try testing.expectError(error.InvalidMod, set.parse(alloc, "middle_ctrl=super")); +} + +test "RemapSet: isRemapped checks mask" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=super"); + set.finalize(); + + try testing.expect(set.isRemapped(.{ .ctrl = true })); + try testing.expect(!set.isRemapped(.{ .alt = true })); + try testing.expect(!set.isRemapped(.{ .shift = true })); +} + +test "RemapSet: clone creates independent copy" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=super"); + set.finalize(); + + var cloned = try set.clone(alloc); + defer cloned.deinit(alloc); + + try testing.expect(set.equal(cloned)); + try testing.expect(cloned.isRemapped(.{ .ctrl = true })); +} + +test "RemapSet: equal compares correctly" { + const testing = std.testing; + const alloc = testing.allocator; + + var set1: RemapSet = .empty; + defer set1.deinit(alloc); + + var set2: RemapSet = .empty; + defer set2.deinit(alloc); + + try testing.expect(set1.equal(set2)); + + try set1.parse(alloc, "ctrl=super"); + try testing.expect(!set1.equal(set2)); + + try set2.parse(alloc, "ctrl=super"); + try testing.expect(set1.equal(set2)); + + try set1.parse(alloc, "alt=shift"); + try testing.expect(!set1.equal(set2)); +} + +test "RemapSet: parseCLI basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 2), set.map.count()); +} + +test "RemapSet: parseCLI empty clears" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 2), set.map.count()); + + try set.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), set.map.count()); +} + +test "RemapSet: parseCLI invalid" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.InvalidValue, set.parseCLI(alloc, "foo=bar")); + try testing.expectError(error.InvalidValue, set.parseCLI(alloc, "ctrl")); +} + +test "RemapSet: parse aliased modifiers" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "cmd=ctrl"); + set.finalize(); + + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl, set.apply(left_super)); +} + +test "RemapSet: parse aliased modifiers command" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "command=alt"); + set.finalize(); + + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + try testing.expectEqual(left_alt, set.apply(left_super)); +} + +test "RemapSet: parse aliased modifiers opt and option" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "opt=super"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_alt)); + + set.deinit(alloc); + set = .empty; + + try set.parse(alloc, "option=shift"); + set.finalize(); + + const left_shift: Mods = .{ .shift = true, .sides = .{ .shift = .left } }; + try testing.expectEqual(left_shift, set.apply(left_alt)); +} + +test "RemapSet: parse aliased modifiers control" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "control=super"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_ctrl)); +} + +test "RemapSet: parse aliased modifiers on target side" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "alt=cmd"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_alt)); +} + +test "RemapSet: formatEntry empty" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + const set: RemapSet = .empty; + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = \n", buf.written()); +} + +test "RemapSet: formatEntry single sided" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "left_ctrl=super"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = left_ctrl=left_super\n", buf.written()); +} + +test "RemapSet: formatEntry unsided creates two entries" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "ctrl=super"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + // Unsided creates both left and right mappings + const written = buf.written(); + try testing.expect(std.mem.indexOf(u8, written, "left_ctrl=left_super") != null); + try testing.expect(std.mem.indexOf(u8, written, "right_ctrl=left_super") != null); +} + +test "RemapSet: formatEntry right sided" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "left_alt=right_ctrl"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = left_alt=right_ctrl\n", buf.written()); +} diff --git a/vendor/libghostty-vt/src/input/keyboard.zig b/vendor/libghostty-vt/src/input/keyboard.zig new file mode 100644 index 00000000..d2882a23 --- /dev/null +++ b/vendor/libghostty-vt/src/input/keyboard.zig @@ -0,0 +1,58 @@ +const std = @import("std"); +const OptionAsAlt = @import("config.zig").OptionAsAlt; + +/// Keyboard layouts. +/// +/// These aren't heavily used in Ghostty and having a fully comprehensive +/// list is not important. We only need to distinguish between a few +/// different layouts for some nice-to-have features, such as setting a default +/// value for "macos-option-as-alt". +pub const Layout = enum { + // Unknown, unmapped layout. Ghostty should not make any assumptions + // about the layout of the keyboard. + unknown, + + // The remaining should be fairly self-explanatory: + us_standard, + us_international, + + /// Map an Apple keyboard layout ID to a value in this enum. The layout + /// ID can be retrieved using Carbon's TIKeyboardLayoutGetInputSourceProperty + /// function. + /// + /// Even though our layout supports "unknown", we return null if we don't + /// recognize the layout ID so callers can detect this scenario. + pub fn mapAppleId(id: []const u8) ?Layout { + if (std.mem.eql(u8, id, "com.apple.keylayout.US")) { + return .us_standard; + } else if (std.mem.eql(u8, id, "com.apple.keylayout.USInternational")) { + return .us_international; + } + + return null; + } + + /// Returns the default macos-option-as-alt value for this layout. + /// + /// We apply some heuristics to change the default based on the keyboard + /// layout if "macos-option-as-alt" is unset. We do this because on some + /// keyboard layouts such as US standard layouts, users generally expect + /// an input such as option-b to map to alt-b but macOS by default will + /// convert it to the codepoint "∫". + /// + /// This behavior however is desired on international layout where the + /// option key is used for important, regularly used inputs. + pub fn detectOptionAsAlt(self: Layout) OptionAsAlt { + return switch (self) { + // On US standard, the option key is typically used as alt + // and not as a modifier for other codepoints. For example, + // option-B = ∫ but usually the user wants alt-B. + .us_standard, + .us_international, + => .true, + + .unknown, + => .false, + }; + } +}; diff --git a/vendor/libghostty-vt/src/input/keycodes.zig b/vendor/libghostty-vt/src/input/keycodes.zig new file mode 100644 index 00000000..9a3ebc92 --- /dev/null +++ b/vendor/libghostty-vt/src/input/keycodes.zig @@ -0,0 +1,673 @@ +// Based on the Chromium source. The Chromium source code is governed by a +// BSD-style license that can be found in the LICENSE file. +// https://source.chromium.org/chromium/chromium/src/+/main:ui/events/keycodes/dom/dom_code_data.inc + +const std = @import("std"); +const builtin = @import("builtin"); +const Key = @import("key.zig").Key; + +/// The full list of entries for the current platform. +pub const entries: []const Entry = entries: { + const native_idx = switch (builtin.os.tag) { + .ios, .macos => 4, // mac + .windows => 3, // win + .freebsd, .linux => 2, // xkb + else => @compileError("unsupported platform"), + }; + + var result: [raw_entries.len]Entry = undefined; + for (raw_entries, 0..) |raw, i| { + @setEvalBranchQuota(10000); + result[i] = .{ + .key = code_to_key.get(raw[5]) orelse .unidentified, + .usb = raw[0], + .code = raw[5], + .native = raw[native_idx], + }; + } + + const final = result; + break :entries &final; +}; + +/// Entry contains the USB code, native keycode, and W3C dom code for +/// the current platform. +pub const Entry = struct { + key: Key, // input key enum + usb: u32, // USB HID usage code + native: u32, // Native keycode + code: []const u8, // W3C DOM code, static memory +}; + +/// A map from code to key. This isn't meant to be used at runtime +/// (though it could), so it isn't exported. It used to build the +/// key value for Entry. +const code_to_key = code_to_key: { + @setEvalBranchQuota(5000); + break :code_to_key std.StaticStringMap(Key).initComptime(.{ + .{ "KeyA", .key_a }, + .{ "KeyB", .key_b }, + .{ "KeyC", .key_c }, + .{ "KeyD", .key_d }, + .{ "KeyE", .key_e }, + .{ "KeyF", .key_f }, + .{ "KeyG", .key_g }, + .{ "KeyH", .key_h }, + .{ "KeyI", .key_i }, + .{ "KeyJ", .key_j }, + .{ "KeyK", .key_k }, + .{ "KeyL", .key_l }, + .{ "KeyM", .key_m }, + .{ "KeyN", .key_n }, + .{ "KeyO", .key_o }, + .{ "KeyP", .key_p }, + .{ "KeyQ", .key_q }, + .{ "KeyR", .key_r }, + .{ "KeyS", .key_s }, + .{ "KeyT", .key_t }, + .{ "KeyU", .key_u }, + .{ "KeyV", .key_v }, + .{ "KeyW", .key_w }, + .{ "KeyX", .key_x }, + .{ "KeyY", .key_y }, + .{ "KeyZ", .key_z }, + .{ "Digit1", .digit_1 }, + .{ "Digit2", .digit_2 }, + .{ "Digit3", .digit_3 }, + .{ "Digit4", .digit_4 }, + .{ "Digit5", .digit_5 }, + .{ "Digit6", .digit_6 }, + .{ "Digit7", .digit_7 }, + .{ "Digit8", .digit_8 }, + .{ "Digit9", .digit_9 }, + .{ "Digit0", .digit_0 }, + .{ "Enter", .enter }, + .{ "Escape", .escape }, + .{ "Backspace", .backspace }, + .{ "Tab", .tab }, + .{ "Space", .space }, + .{ "Minus", .minus }, + .{ "Equal", .equal }, + .{ "BracketLeft", .bracket_left }, + .{ "BracketRight", .bracket_right }, + .{ "Backslash", .backslash }, + .{ "Semicolon", .semicolon }, + .{ "Quote", .quote }, + .{ "Backquote", .backquote }, + .{ "Comma", .comma }, + .{ "Period", .period }, + .{ "Slash", .slash }, + .{ "CapsLock", .caps_lock }, + .{ "F1", .f1 }, + .{ "F2", .f2 }, + .{ "F3", .f3 }, + .{ "F4", .f4 }, + .{ "F5", .f5 }, + .{ "F6", .f6 }, + .{ "F7", .f7 }, + .{ "F8", .f8 }, + .{ "F9", .f9 }, + .{ "F10", .f10 }, + .{ "F11", .f11 }, + .{ "F12", .f12 }, + .{ "F13", .f13 }, + .{ "F14", .f14 }, + .{ "F15", .f15 }, + .{ "F16", .f16 }, + .{ "F17", .f17 }, + .{ "F18", .f18 }, + .{ "F19", .f19 }, + .{ "F20", .f20 }, + .{ "F21", .f21 }, + .{ "F22", .f22 }, + .{ "F23", .f23 }, + .{ "F24", .f24 }, + .{ "PrintScreen", .print_screen }, + .{ "ScrollLock", .scroll_lock }, + .{ "Pause", .pause }, + .{ "Insert", .insert }, + .{ "Home", .home }, + .{ "PageUp", .page_up }, + .{ "Delete", .delete }, + .{ "End", .end }, + .{ "Copy", .copy }, + .{ "Cut", .cut }, + .{ "Paste", .paste }, + .{ "PageDown", .page_down }, + .{ "ArrowRight", .arrow_right }, + .{ "ArrowLeft", .arrow_left }, + .{ "ArrowDown", .arrow_down }, + .{ "ArrowUp", .arrow_up }, + .{ "NumLock", .num_lock }, + .{ "NumpadDivide", .numpad_divide }, + .{ "NumpadMultiply", .numpad_multiply }, + .{ "NumpadSubtract", .numpad_subtract }, + .{ "NumpadAdd", .numpad_add }, + .{ "NumpadEnter", .numpad_enter }, + .{ "Numpad1", .numpad_1 }, + .{ "Numpad2", .numpad_2 }, + .{ "Numpad3", .numpad_3 }, + .{ "Numpad4", .numpad_4 }, + .{ "Numpad5", .numpad_5 }, + .{ "Numpad6", .numpad_6 }, + .{ "Numpad7", .numpad_7 }, + .{ "Numpad8", .numpad_8 }, + .{ "Numpad9", .numpad_9 }, + .{ "Numpad0", .numpad_0 }, + .{ "NumpadDecimal", .numpad_decimal }, + .{ "NumpadEqual", .numpad_equal }, + .{ "ContextMenu", .context_menu }, + .{ "ControlLeft", .control_left }, + .{ "ShiftLeft", .shift_left }, + .{ "AltLeft", .alt_left }, + .{ "MetaLeft", .meta_left }, + .{ "ControlRight", .control_right }, + .{ "ShiftRight", .shift_right }, + .{ "AltRight", .alt_right }, + .{ "MetaRight", .meta_right }, + }); +}; + +/// The codes for the table from the Chromium data set. These are ALL the +/// codes, not just the ones that are supported by the current platform. +/// These are `pub` but you shouldn't use this because it uses way more +/// memory than is necessary. +/// +/// The format is: usb, evdev, xkb, win, mac, code +pub const RawEntry = struct { u32, u32, u32, u32, u32, []const u8 }; + +/// All of the full entries. This is marked pub but it should NOT be referenced +/// directly because it contains too much data for normal usage. Use `entries` +/// instead which contains just the relevant data for the target platform. +pub const raw_entries: []const RawEntry = &.{ + // USB evdev XKB Win Mac Code + .{ 0x000000, 0x0000, 0x0000, 0x0000, 0xffff, "" }, + + // ========================================= + // Non-USB codes + // ========================================= + + // USB evdev XKB Win Mac Code + .{ 0x000010, 0x0000, 0x0000, 0x0000, 0xffff, "Hyper" }, + .{ 0x000011, 0x0000, 0x0000, 0x0000, 0xffff, "Super" }, + .{ 0x000012, 0x0000, 0x0000, 0x0000, 0xffff, "Fn" }, + .{ 0x000013, 0x0000, 0x0000, 0x0000, 0xffff, "FnLock" }, + .{ 0x000014, 0x0000, 0x0000, 0x0000, 0xffff, "Suspend" }, + .{ 0x000015, 0x0000, 0x0000, 0x0000, 0xffff, "Resume" }, + .{ 0x000016, 0x0000, 0x0000, 0x0000, 0xffff, "Turbo" }, + + // ========================================= + // USB Usage Page 0x01: Generic Desktop Page + // ========================================= + + // Sleep could be encoded as USB#0c0032, but there's no corresponding WakeUp + // in the 0x0c USB page. + // USB evdev XKB Win Mac + .{ 0x010082, 0x008e, 0x0096, 0xe05f, 0xffff, "Sleep" }, + .{ 0x010083, 0x008f, 0x0097, 0xe063, 0xffff, "WakeUp" }, + .{ 0x0100a9, 0x00f8, 0x0100, 0x0000, 0xffff, "" }, + .{ 0x0100b5, 0x00e3, 0x00eb, 0x0000, 0xffff, "DisplayToggleIntExt" }, + + // ========================================= + // USB Usage Page 0x07: Keyboard/Keypad Page + // ========================================= + + // TODO(garykac): + // XKB#005c ISO Level3 Shift (AltGr) + // XKB#005e <>|| + // XKB#006d Linefeed + // XKB#008a SunProps cf. USB#0700a3 CrSel/Props + // XKB#008e SunOpen + // Mac#003f kVK_Function + // Mac#000a kVK_ISO_Section (ISO keyboards only) + // Mac#0066 kVK_JIS_Eisu (USB#07008a Henkan?) + + // USB evdev XKB Win Mac + .{ 0x070000, 0x0000, 0x0000, 0x0000, 0xffff, "" }, + .{ 0x070001, 0x0000, 0x0000, 0x00ff, 0xffff, "" }, + .{ 0x070002, 0x0000, 0x0000, 0x00fc, 0xffff, "" }, + .{ 0x070003, 0x0000, 0x0000, 0x0000, 0xffff, "" }, + .{ 0x070004, 0x001e, 0x0026, 0x001e, 0x0000, "KeyA" }, + .{ 0x070005, 0x0030, 0x0038, 0x0030, 0x000b, "KeyB" }, + .{ 0x070006, 0x002e, 0x0036, 0x002e, 0x0008, "KeyC" }, + .{ 0x070007, 0x0020, 0x0028, 0x0020, 0x0002, "KeyD" }, + + .{ 0x070008, 0x0012, 0x001a, 0x0012, 0x000e, "KeyE" }, + .{ 0x070009, 0x0021, 0x0029, 0x0021, 0x0003, "KeyF" }, + .{ 0x07000a, 0x0022, 0x002a, 0x0022, 0x0005, "KeyG" }, + .{ 0x07000b, 0x0023, 0x002b, 0x0023, 0x0004, "KeyH" }, + .{ 0x07000c, 0x0017, 0x001f, 0x0017, 0x0022, "KeyI" }, + .{ 0x07000d, 0x0024, 0x002c, 0x0024, 0x0026, "KeyJ" }, + .{ 0x07000e, 0x0025, 0x002d, 0x0025, 0x0028, "KeyK" }, + .{ 0x07000f, 0x0026, 0x002e, 0x0026, 0x0025, "KeyL" }, + + .{ 0x070010, 0x0032, 0x003a, 0x0032, 0x002e, "KeyM" }, + .{ 0x070011, 0x0031, 0x0039, 0x0031, 0x002d, "KeyN" }, + .{ 0x070012, 0x0018, 0x0020, 0x0018, 0x001f, "KeyO" }, + .{ 0x070013, 0x0019, 0x0021, 0x0019, 0x0023, "KeyP" }, + .{ 0x070014, 0x0010, 0x0018, 0x0010, 0x000c, "KeyQ" }, + .{ 0x070015, 0x0013, 0x001b, 0x0013, 0x000f, "KeyR" }, + .{ 0x070016, 0x001f, 0x0027, 0x001f, 0x0001, "KeyS" }, + .{ 0x070017, 0x0014, 0x001c, 0x0014, 0x0011, "KeyT" }, + + .{ 0x070018, 0x0016, 0x001e, 0x0016, 0x0020, "KeyU" }, + .{ 0x070019, 0x002f, 0x0037, 0x002f, 0x0009, "KeyV" }, + .{ 0x07001a, 0x0011, 0x0019, 0x0011, 0x000d, "KeyW" }, + .{ 0x07001b, 0x002d, 0x0035, 0x002d, 0x0007, "KeyX" }, + .{ 0x07001c, 0x0015, 0x001d, 0x0015, 0x0010, "KeyY" }, + .{ 0x07001d, 0x002c, 0x0034, 0x002c, 0x0006, "KeyZ" }, + .{ 0x07001e, 0x0002, 0x000a, 0x0002, 0x0012, "Digit1" }, + .{ 0x07001f, 0x0003, 0x000b, 0x0003, 0x0013, "Digit2" }, + + .{ 0x070020, 0x0004, 0x000c, 0x0004, 0x0014, "Digit3" }, + .{ 0x070021, 0x0005, 0x000d, 0x0005, 0x0015, "Digit4" }, + .{ 0x070022, 0x0006, 0x000e, 0x0006, 0x0017, "Digit5" }, + .{ 0x070023, 0x0007, 0x000f, 0x0007, 0x0016, "Digit6" }, + .{ 0x070024, 0x0008, 0x0010, 0x0008, 0x001a, "Digit7" }, + .{ 0x070025, 0x0009, 0x0011, 0x0009, 0x001c, "Digit8" }, + .{ 0x070026, 0x000a, 0x0012, 0x000a, 0x0019, "Digit9" }, + .{ 0x070027, 0x000b, 0x0013, 0x000b, 0x001d, "Digit0" }, + + .{ 0x070028, 0x001c, 0x0024, 0x001c, 0x0024, "Enter" }, + .{ 0x070029, 0x0001, 0x0009, 0x0001, 0x0035, "Escape" }, + .{ 0x07002a, 0x000e, 0x0016, 0x000e, 0x0033, "Backspace" }, + .{ 0x07002b, 0x000f, 0x0017, 0x000f, 0x0030, "Tab" }, + .{ 0x07002c, 0x0039, 0x0041, 0x0039, 0x0031, "Space" }, + .{ 0x07002d, 0x000c, 0x0014, 0x000c, 0x001b, "Minus" }, + .{ 0x07002e, 0x000d, 0x0015, 0x000d, 0x0018, "Equal" }, + .{ 0x07002f, 0x001a, 0x0022, 0x001a, 0x0021, "BracketLeft" }, + + .{ 0x070030, 0x001b, 0x0023, 0x001b, 0x001e, "BracketRight" }, + .{ 0x070031, 0x002b, 0x0033, 0x002b, 0x002a, "Backslash" }, + // USB#070032 never appears on keyboards that have USB#070031. + // Platforms use the same scancode as for the two keys. + // Hence this code can only be generated synthetically + // (e.g. in a DOM Level 3 KeyboardEvent). + // The keycap varies on international keyboards: + // Dan: '* Dutch: <> Ger: #' UK: #~ + // TODO(garykac): Verify Mac intl keyboard. + //.{ 0x070032, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + .{ 0x070033, 0x0027, 0x002f, 0x0027, 0x0029, "Semicolon" }, + .{ 0x070034, 0x0028, 0x0030, 0x0028, 0x0027, "Quote" }, + .{ 0x070035, 0x0029, 0x0031, 0x0029, 0x0032, "Backquote" }, + .{ 0x070036, 0x0033, 0x003b, 0x0033, 0x002b, "Comma" }, + .{ 0x070037, 0x0034, 0x003c, 0x0034, 0x002f, "Period" }, + + .{ 0x070038, 0x0035, 0x003d, 0x0035, 0x002c, "Slash" }, + // TODO(garykac): CapsLock requires special handling for each platform. + .{ 0x070039, 0x003a, 0x0042, 0x003a, 0x0039, "CapsLock" }, + .{ 0x07003a, 0x003b, 0x0043, 0x003b, 0x007a, "F1" }, + .{ 0x07003b, 0x003c, 0x0044, 0x003c, 0x0078, "F2" }, + .{ 0x07003c, 0x003d, 0x0045, 0x003d, 0x0063, "F3" }, + .{ 0x07003d, 0x003e, 0x0046, 0x003e, 0x0076, "F4" }, + .{ 0x07003e, 0x003f, 0x0047, 0x003f, 0x0060, "F5" }, + .{ 0x07003f, 0x0040, 0x0048, 0x0040, 0x0061, "F6" }, + + .{ 0x070040, 0x0041, 0x0049, 0x0041, 0x0062, "F7" }, + .{ 0x070041, 0x0042, 0x004a, 0x0042, 0x0064, "F8" }, + .{ 0x070042, 0x0043, 0x004b, 0x0043, 0x0065, "F9" }, + .{ 0x070043, 0x0044, 0x004c, 0x0044, 0x006d, "F10" }, + .{ 0x070044, 0x0057, 0x005f, 0x0057, 0x0067, "F11" }, + .{ 0x070045, 0x0058, 0x0060, 0x0058, 0x006f, "F12" }, + // PrintScreen is effectively F13 on Mac OS X. + .{ 0x070046, 0x0063, 0x006b, 0xe037, 0xffff, "PrintScreen" }, + .{ 0x070047, 0x0046, 0x004e, 0x0046, 0xffff, "ScrollLock" }, + + .{ 0x070048, 0x0077, 0x007f, 0x0045, 0xffff, "Pause" }, + // USB#0x070049 Insert, labeled "Help/Insert" on Mac -- see note M1 at top. + .{ 0x070049, 0x006e, 0x0076, 0xe052, 0x0072, "Insert" }, + .{ 0x07004a, 0x0066, 0x006e, 0xe047, 0x0073, "Home" }, + .{ 0x07004b, 0x0068, 0x0070, 0xe049, 0x0074, "PageUp" }, + // Delete (Forward Delete) named DEL because DELETE conflicts with <windows.h> + .{ 0x07004c, 0x006f, 0x0077, 0xe053, 0x0075, "Delete" }, + .{ 0x07004d, 0x006b, 0x0073, 0xe04f, 0x0077, "End" }, + .{ 0x07004e, 0x006d, 0x0075, 0xe051, 0x0079, "PageDown" }, + .{ 0x07004f, 0x006a, 0x0072, 0xe04d, 0x007c, "ArrowRight" }, + + .{ 0x070050, 0x0069, 0x0071, 0xe04b, 0x007b, "ArrowLeft" }, + .{ 0x070051, 0x006c, 0x0074, 0xe050, 0x007d, "ArrowDown" }, + .{ 0x070052, 0x0067, 0x006f, 0xe048, 0x007e, "ArrowUp" }, + .{ 0x070053, 0x0045, 0x004d, 0xe045, 0x0047, "NumLock" }, + .{ 0x070054, 0x0062, 0x006a, 0xe035, 0x004b, "NumpadDivide" }, + .{ 0x070055, 0x0037, 0x003f, 0x0037, 0x0043, "NumpadMultiply" }, + .{ 0x070056, 0x004a, 0x0052, 0x004a, 0x004e, "NumpadSubtract" }, + .{ 0x070057, 0x004e, 0x0056, 0x004e, 0x0045, "NumpadAdd" }, + + .{ 0x070058, 0x0060, 0x0068, 0xe01c, 0x004c, "NumpadEnter" }, + .{ 0x070059, 0x004f, 0x0057, 0x004f, 0x0053, "Numpad1" }, // +End + .{ 0x07005a, 0x0050, 0x0058, 0x0050, 0x0054, "Numpad2" }, // +Down + .{ 0x07005b, 0x0051, 0x0059, 0x0051, 0x0055, "Numpad3" }, // +PageDown + .{ 0x07005c, 0x004b, 0x0053, 0x004b, 0x0056, "Numpad4" }, // +Left + .{ 0x07005d, 0x004c, 0x0054, 0x004c, 0x0057, "Numpad5" }, + .{ 0x07005e, 0x004d, 0x0055, 0x004d, 0x0058, "Numpad6" }, // +Right + .{ 0x07005f, 0x0047, 0x004f, 0x0047, 0x0059, "Numpad7" }, // +Home + .{ 0x070060, 0x0048, 0x0050, 0x0048, 0x005b, "Numpad8" }, // +Up + .{ 0x070061, 0x0049, 0x0051, 0x0049, 0x005c, "Numpad9" }, // +PageUp + .{ 0x070062, 0x0052, 0x005a, 0x0052, 0x0052, "Numpad0" }, // +Insert + .{ 0x070063, 0x0053, 0x005b, 0x0053, 0x0041, "NumpadDecimal" }, // +Delete + // USB#070064 is not present on US keyboard. + // This key is typically located near LeftShift key. + // The keycap varies on international keyboards: + // Dan: <> Dutch: ][ Ger: <> UK: \| + .{ 0x070064, 0x0056, 0x005e, 0x0056, 0x000a, "IntlBackslash" }, + // USB#0x070065 Application Menu (next to RWin key) -- see note L2 at top. + .{ 0x070065, 0x007f, 0x0087, 0xe05d, 0x006e, "ContextMenu" }, + .{ 0x070066, 0x0074, 0x007c, 0xe05e, 0xffff, "Power" }, + .{ 0x070067, 0x0075, 0x007d, 0x0059, 0x0051, "NumpadEqual" }, + + .{ 0x070068, 0x00b7, 0x00bf, 0x0064, 0x0069, "F13" }, + .{ 0x070069, 0x00b8, 0x00c0, 0x0065, 0x006b, "F14" }, + .{ 0x07006a, 0x00b9, 0x00c1, 0x0066, 0x0071, "F15" }, + .{ 0x07006b, 0x00ba, 0x00c2, 0x0067, 0x006a, "F16" }, + .{ 0x07006c, 0x00bb, 0x00c3, 0x0068, 0x0040, "F17" }, + .{ 0x07006d, 0x00bc, 0x00c4, 0x0069, 0x004f, "F18" }, + .{ 0x07006e, 0x00bd, 0x00c5, 0x006a, 0x0050, "F19" }, + .{ 0x07006f, 0x00be, 0x00c6, 0x006b, 0x005a, "F20" }, + + .{ 0x070070, 0x00bf, 0x00c7, 0x006c, 0xffff, "F21" }, + .{ 0x070071, 0x00c0, 0x00c8, 0x006d, 0xffff, "F22" }, + .{ 0x070072, 0x00c1, 0x00c9, 0x006e, 0xffff, "F23" }, + // USB#0x070073 -- see note W1 at top. + .{ 0x070073, 0x00c2, 0x00ca, 0x0076, 0xffff, "F24" }, + .{ 0x070074, 0x0086, 0x008e, 0x0000, 0xffff, "Open" }, + // USB#0x070075 Help -- see note M1 at top. + .{ 0x070075, 0x008a, 0x0092, 0xe03b, 0xffff, "Help" }, + // USB#0x070076 Keyboard Menu -- see note L2 at top. + //.{ 0x070076, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + .{ 0x070077, 0x0084, 0x008c, 0x0000, 0xffff, "Select" }, + + //.{ 0x070078, 0x0080, 0x0088, 0x0000, 0xffff, ""}, + .{ 0x070079, 0x0081, 0x0089, 0x0000, 0xffff, "Again" }, + .{ 0x07007a, 0x0083, 0x008b, 0xe008, 0xffff, "Undo" }, + .{ 0x07007b, 0x0089, 0x0091, 0xe017, 0xffff, "Cut" }, + .{ 0x07007c, 0x0085, 0x008d, 0xe018, 0xffff, "Copy" }, + .{ 0x07007d, 0x0087, 0x008f, 0xe00a, 0xffff, "Paste" }, + .{ 0x07007e, 0x0088, 0x0090, 0x0000, 0xffff, "Find" }, + .{ 0x07007f, 0x0071, 0x0079, 0xe020, 0x004a, "AudioVolumeMute" }, + + .{ 0x070080, 0x0073, 0x007b, 0xe030, 0x0048, "AudioVolumeUp" }, + .{ 0x070081, 0x0072, 0x007a, 0xe02e, 0x0049, "AudioVolumeDown" }, + //.{ 0x070082, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x070083, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x070084, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + .{ 0x070085, 0x0079, 0x0081, 0x007e, 0x005f, "NumpadComma" }, + + // International1 + // USB#070086 is used on AS/400 keyboards. Standard Keypad_= is USB#070067. + //.{ 0x070086, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#070087 is used for Brazilian /? and Japanese _ 'ro'. + .{ 0x070087, 0x0059, 0x0061, 0x0073, 0x005e, "IntlRo" }, + // International2 + // USB#070088 is used as Japanese Hiragana/Katakana key. + .{ 0x070088, 0x005d, 0x0065, 0x0070, 0xffff, "KanaMode" }, + // International3 + // USB#070089 is used as Japanese Yen key. + .{ 0x070089, 0x007c, 0x0084, 0x007d, 0x005d, "IntlYen" }, + // International4 + // USB#07008a is used as Japanese Henkan (Convert) key. + .{ 0x07008a, 0x005c, 0x0064, 0x0079, 0xffff, "Convert" }, + // International5 + // USB#07008b is used as Japanese Muhenkan (No-convert) key. + .{ 0x07008b, 0x005e, 0x0066, 0x007b, 0xffff, "NonConvert" }, + //.{ 0x07008c, 0x005f, 0x0067, 0x005c, 0xffff, ""}, + //.{ 0x07008d, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x07008e, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x07008f, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // LANG1 + // USB#070090 is used as Korean Hangul/English toggle key, and as the Kana key + // on the Apple Japanese keyboard. + .{ 0x070090, 0x007a, 0x0082, 0x0072, 0x0068, "Lang1" }, + // LANG2 + // USB#070091 is used as Korean Hanja conversion key, and as the Eisu key on + // the Apple Japanese keyboard. + .{ 0x070091, 0x007b, 0x0083, 0x0071, 0x0066, "Lang2" }, + // LANG3 + // USB#070092 is used as Japanese Katakana key. + .{ 0x070092, 0x005a, 0x0062, 0x0078, 0xffff, "Lang3" }, + // LANG4 + // USB#070093 is used as Japanese Hiragana key. + .{ 0x070093, 0x005b, 0x0063, 0x0077, 0xffff, "Lang4" }, + // LANG5 + // USB#070094 is used as Japanese Zenkaku/Hankaku (Fullwidth/halfwidth) key. + // Not mapped on Windows -- see note W1 at top. + .{ 0x070094, 0x0055, 0x005d, 0x0000, 0xffff, "Lang5" }, + //.{ 0x070095, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x070096, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x070097, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x070098, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + //.{ 0x070099, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x07009a, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + .{ 0x07009b, 0x0000, 0x0000, 0x0000, 0xffff, "Abort" }, + // USB#0x07009c Keyboard Clear -- see note L1 at top. + //.{ 0x07009c, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x07009d, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x07009e, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x07009f, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + //.{ 0x0700a0, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700a1, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700a2, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#0x0700a3 Props -- see note L2 at top. + .{ 0x0700a3, 0x0000, 0x0000, 0x0000, 0xffff, "Props" }, + //.{ 0x0700a4, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + //.{ 0x0700b0, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700b1, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700b2, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700b3, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700b4, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700b5, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + .{ 0x0700b6, 0x00b3, 0x00bb, 0x0000, 0xffff, "NumpadParenLeft" }, + .{ 0x0700b7, 0x00b4, 0x00bc, 0x0000, 0xffff, "NumpadParenRight" }, + + //.{ 0x0700b8, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700b9, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700ba, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + .{ 0x0700bb, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadBackspace" }, + //.{ 0x0700bc, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700bd, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700be, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700bf, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + //.{ 0x0700c0, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700c1, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700c2, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700c3, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700c4, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700c5, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700c6, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700c7, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + //.{ 0x0700c8, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700c9, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700ca, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // NUMPAD_DOUBLE_VERTICAL_BAR), // Keypad_|| + //.{ 0x0700cb, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700cc, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700cd, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700ce, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700cf, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + .{ 0x0700d0, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemoryStore" }, + .{ 0x0700d1, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemoryRecall" }, + .{ 0x0700d2, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemoryClear" }, + .{ 0x0700d3, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemoryAdd" }, + .{ 0x0700d4, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemorySubtract" }, + //.{ 0x0700d5, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700d6, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + .{ 0x0700d7, 0x0076, 0x007e, 0x0000, 0xffff, "" }, + // USB#0x0700d8 Keypad Clear -- see note L1 at top. + .{ 0x0700d8, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadClear" }, + .{ 0x0700d9, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadClearEntry" }, + //.{ 0x0700da, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700db, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700dc, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0700dd, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // USB#0700de - #0700df are reserved. + .{ 0x0700e0, 0x001d, 0x0025, 0x001d, 0x003b, "ControlLeft" }, + .{ 0x0700e1, 0x002a, 0x0032, 0x002a, 0x0038, "ShiftLeft" }, + // USB#0700e2: left Alt key (Mac left Option key). + .{ 0x0700e2, 0x0038, 0x0040, 0x0038, 0x003a, "AltLeft" }, + // USB#0700e3: left GUI key, e.g. Windows, Mac Command, ChromeOS Search. + .{ 0x0700e3, 0x007d, 0x0085, 0xe05b, 0x0037, "MetaLeft" }, + .{ 0x0700e4, 0x0061, 0x0069, 0xe01d, 0x003e, "ControlRight" }, + .{ 0x0700e5, 0x0036, 0x003e, 0x0036, 0x003c, "ShiftRight" }, + // USB#0700e6: right Alt key (Mac right Option key). + .{ 0x0700e6, 0x0064, 0x006c, 0xe038, 0x003d, "AltRight" }, + // USB#0700e7: right GUI key, e.g. Windows, Mac Command, ChromeOS Search. + .{ 0x0700e7, 0x007e, 0x0086, 0xe05c, 0x0036, "MetaRight" }, + + // USB#0700e8 - #07ffff are reserved + + // ================================== + // USB Usage Page 0x0c: Consumer Page + // ================================== + // AL = Application Launch + // AC = Application Control + + // TODO(garykac): Many XF86 keys have multiple scancodes mapping to them. + // We need to map all of these into a canonical USB scancode without + // confusing the reverse-lookup - most likely by simply returning the first + // found match. + + // TODO(garykac): Find appropriate mappings for: + // Win#e03c Music - USB#0c0193 is AL_AVCapturePlayback + // Win#e064 Pictures + // XKB#0080 XF86LaunchA + // XKB#0099 XF86Send + // XKB#009b XF86Xfer + // XKB#009c XF86Launch1 + // XKB#009d XF86Launch2 + // XKB... remaining XF86 keys + + // KEY_BRIGHTNESS* added in Linux 3.16 + // http://www.usb.org/developers/hidpage/HUTRR41.pdf + // + // Keyboard backlight/illumination spec update. + // https://www.usb.org/sites/default/files/hutrr73_-_fn_key_and_keyboard_backlight_brightness_0.pdf + // USB evdev XKB Win Mac Code + .{ 0x0c0060, 0x0166, 0x016e, 0x0000, 0xffff, "" }, + .{ 0x0c0061, 0x0172, 0x017a, 0x0000, 0xffff, "" }, + .{ 0x0c006f, 0x00e1, 0x00e9, 0x0000, 0xffff, "BrightnessUp" }, + .{ 0x0c0070, 0x00e0, 0x00e8, 0x0000, 0xffff, "BrightnessDown" }, + .{ 0x0c0072, 0x01af, 0x01b7, 0x0000, 0xffff, "" }, + .{ 0x0c0073, 0x0250, 0x0258, 0x0000, 0xffff, "" }, + .{ 0x0c0074, 0x0251, 0x0259, 0x0000, 0xffff, "" }, + .{ 0x0c0075, 0x00f4, 0x00fc, 0x0000, 0xffff, "" }, + .{ 0x0c0079, 0x00e6, 0x00ee, 0x0000, 0xffff, "" }, + .{ 0x0c007a, 0x00e5, 0x00ed, 0x0000, 0xffff, "" }, + .{ 0x0c007c, 0x00e4, 0x00ec, 0x0000, 0xffff, "" }, + .{ 0x0c0083, 0x0195, 0x019d, 0x0000, 0xffff, "" }, + .{ 0x0c008c, 0x00a9, 0x00b1, 0x0000, 0xffff, "" }, + .{ 0x0c008d, 0x016a, 0x0172, 0x0000, 0xffff, "" }, + .{ 0x0c0094, 0x00ae, 0x00b6, 0x0000, 0xffff, "" }, + .{ 0x0c009c, 0x019a, 0x01a2, 0x0000, 0xffff, "" }, + .{ 0x0c009d, 0x019b, 0x01a3, 0x0000, 0xffff, "" }, + + // USB evdev XKB Win Mac + .{ 0x0c00b0, 0x00cf, 0x00d7, 0x0000, 0xffff, "MediaPlay" }, + .{ 0x0c00b1, 0x00c9, 0x00d1, 0x0000, 0xffff, "MediaPause" }, + .{ 0x0c00b2, 0x00a7, 0x00af, 0x0000, 0xffff, "MediaRecord" }, + .{ 0x0c00b3, 0x00d0, 0x00d8, 0x0000, 0xffff, "MediaFastForward" }, + .{ 0x0c00b4, 0x00a8, 0x00b0, 0x0000, 0xffff, "MediaRewind" }, + .{ 0x0c00b5, 0x00a3, 0x00ab, 0xe019, 0xffff, "MediaTrackNext" }, + .{ 0x0c00b6, 0x00a5, 0x00ad, 0xe010, 0xffff, "MediaTrackPrevious" }, + .{ 0x0c00b7, 0x00a6, 0x00ae, 0xe024, 0xffff, "MediaStop" }, + .{ 0x0c00b8, 0x00a1, 0x00a9, 0xe02c, 0xffff, "Eject" }, + .{ 0x0c00cd, 0x00a4, 0x00ac, 0xe022, 0xffff, "MediaPlayPause" }, + .{ 0x0c00cf, 0x0246, 0x024e, 0x0000, 0xffff, "" }, + .{ 0x0c00d8, 0x024a, 0x0252, 0x0000, 0xffff, "" }, + .{ 0x0c00d9, 0x0249, 0x0251, 0x0000, 0xffff, "" }, + .{ 0x0c00e5, 0x00d1, 0x00d9, 0x0000, 0xffff, "" }, + //.{ 0x0c00e6, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0c0150, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0c0151, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0c0152, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0c0153, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0c0154, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + //.{ 0x0c0155, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#0c0183: AL Consumer Control Configuration + .{ 0x0c0183, 0x00ab, 0x00b3, 0xe06d, 0xffff, "MediaSelect" }, + .{ 0x0c0184, 0x01a5, 0x01ad, 0x0000, 0xffff, "" }, + .{ 0x0c0186, 0x01a7, 0x01af, 0x0000, 0xffff, "" }, + // USB#0x0c018a AL_EmailReader + .{ 0x0c018a, 0x009b, 0x00a3, 0xe06c, 0xffff, "LaunchMail" }, + // USB#0x0c018d: AL Contacts/Address Book + .{ 0x0c018d, 0x01ad, 0x01b5, 0x0000, 0xffff, "" }, + // USB#0x0c018e: AL Calendar/Schedule + .{ 0x0c018e, 0x018d, 0x0195, 0x0000, 0xffff, "" }, + // USB#0x0c018f AL Task/Project Manager + //.{ 0x0c018f, 0x0241, 0x0249, 0x0000, 0xffff, ""}, + // USB#0x0c0190: AL Log/Journal/Timecard + //.{ 0x0c0190, 0x0242, 0x024a, 0x0000, 0xffff, ""}, + // USB#0x0c0192: AL_Calculator + .{ 0x0c0192, 0x008c, 0x0094, 0xe021, 0xffff, "LaunchApp2" }, + // USB#0c0194: My Computer (AL_LocalMachineBrowser) + .{ 0x0c0194, 0x0090, 0x0098, 0xe06b, 0xffff, "LaunchApp1" }, + .{ 0x0c0196, 0x0096, 0x009e, 0x0000, 0xffff, "" }, + .{ 0x0c019C, 0x01b1, 0x01b9, 0x0000, 0xffff, "" }, + // USB#0x0c019e: AL Terminal Lock/Screensaver + .{ 0x0c019e, 0x0098, 0x00a0, 0x0000, 0xffff, "" }, + // USB#0x0c019f AL Control Panel + .{ 0x0c019f, 0x0243, 0x024b, 0x0000, 0xffff, "LaunchControlPanel" }, + // USB#0x0c01a2: AL Select Task/Application + .{ 0x0c01a2, 0x0244, 0x024c, 0x0000, 0xffff, "SelectTask" }, + // USB#0x0c01a7: AL_Documents + .{ 0x0c01a7, 0x00eb, 0x00f3, 0x0000, 0xffff, "" }, + .{ 0x0c01ab, 0x01b0, 0x01b8, 0x0000, 0xffff, "" }, + // USB#0x0c01ae: AL Keyboard Layout + .{ 0x0c01ae, 0x0176, 0x017e, 0x0000, 0xffff, "" }, + .{ 0x0c01b1, 0x0245, 0x024d, 0x0000, 0xffff, "LaunchScreenSaver" }, + .{ 0x0c01cb, 0x0247, 0x024f, 0x0000, 0xffff, "LaunchAssistant" }, + // USB#0c01b4: Home Directory (AL_FileBrowser) (Explorer) + //.{ 0x0c01b4, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#0x0c01b7: AL Audio Browser + .{ 0x0c01b7, 0x0188, 0x0190, 0x0000, 0xffff, "" }, + // USB#0x0c0201: AC New + .{ 0x0c0201, 0x00b5, 0x00bd, 0x0000, 0xffff, "" }, + // USB#0x0c0203: AC Close + .{ 0x0c0203, 0x00ce, 0x00d6, 0x0000, 0xffff, "" }, + // USB#0x0c0207: AC Close + .{ 0x0c0207, 0x00ea, 0x00f2, 0x0000, 0xffff, "" }, + // USB#0x0c0208: AC Print + .{ 0x0c0208, 0x00d2, 0x00da, 0x0000, 0xffff, "" }, + // USB#0x0c0221: AC_Search + .{ 0x0c0221, 0x00d9, 0x00e1, 0xe065, 0xffff, "BrowserSearch" }, + // USB#0x0c0223: AC_Home + .{ 0x0c0223, 0x00ac, 0x00b4, 0xe032, 0xffff, "BrowserHome" }, + // USB#0x0c0224: AC_Back + .{ 0x0c0224, 0x009e, 0x00a6, 0xe06a, 0xffff, "BrowserBack" }, + // USB#0x0c0225: AC_Forward + .{ 0x0c0225, 0x009f, 0x00a7, 0xe069, 0xffff, "BrowserForward" }, + // USB#0x0c0226: AC_Stop + .{ 0x0c0226, 0x0080, 0x0088, 0xe068, 0xffff, "BrowserStop" }, + // USB#0x0c0227: AC_Refresh (Reload) + .{ 0x0c0227, 0x00ad, 0x00b5, 0xe067, 0xffff, "BrowserRefresh" }, + // USB#0x0c022a: AC_Bookmarks (Favorites) + .{ 0x0c022a, 0x009c, 0x00a4, 0xe066, 0xffff, "BrowserFavorites" }, + .{ 0x0c022d, 0x01a2, 0x01aa, 0x0000, 0xffff, "" }, + .{ 0x0c022e, 0x01a3, 0x01ab, 0x0000, 0xffff, "" }, + // USB#0x0c0230: AC Full Screen View + //.{ 0x0c0230, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#0x0c0231: AC Normal View + //.{ 0x0c0231, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + .{ 0x0c0232, 0x0174, 0x017c, 0x0000, 0xffff, "ZoomToggle" }, + // USB#0x0c0279: AC Redo/Repeat + .{ 0x0c0279, 0x00b6, 0x00be, 0x0000, 0xffff, "" }, + // USB#0x0c0289: AC_Reply + .{ 0x0c0289, 0x00e8, 0x00f0, 0x0000, 0xffff, "MailReply" }, + // USB#0x0c028b: AC_ForwardMsg (MailForward) + .{ 0x0c028b, 0x00e9, 0x00f1, 0x0000, 0xffff, "MailForward" }, + // USB#0x0c028c: AC_Send + .{ 0x0c028c, 0x00e7, 0x00ef, 0x0000, 0xffff, "MailSend" }, + // USB#0x0c029d: AC Next Keyboard Layout Select + .{ 0x0c029d, 0x0248, 0x0250, 0x0000, 0xffff, "KeyboardLayoutSelect" }, + .{ 0x0c029f, 0x0078, 0x0080, 0x0000, 0xffff, "ShowAllWindows" }, + .{ 0x0c02a2, 0x00cc, 0x00d4, 0x0000, 0xffff, "" }, + .{ 0x0c02d0, 0x0279, 0x0281, 0x0000, 0xffff, "" }, +}; + +test {} diff --git a/vendor/libghostty-vt/src/input/kitty.zig b/vendor/libghostty-vt/src/input/kitty.zig new file mode 100644 index 00000000..e5789cc4 --- /dev/null +++ b/vendor/libghostty-vt/src/input/kitty.zig @@ -0,0 +1,134 @@ +const key = @import("key.zig"); + +/// A single entry in the kitty keymap data. There are only ~100 entries +/// so the recommendation is to just use a linear search to find the entry +/// for a given key. +pub const Entry = struct { + key: key.Key, + code: u21, + final: u8, + modifier: bool, +}; + +/// The full list of entries for the current platform. +pub const entries: []const Entry = entries: { + var result: [raw_entries.len]Entry = undefined; + for (raw_entries, 0..) |raw, i| { + result[i] = .{ + .key = raw[0], + .code = raw[1], + .final = raw[2], + .modifier = raw[3], + }; + } + + const final = result; + break :entries &final; +}; + +/// Raw entry is the tuple form of an entry for easy human management. +/// This should never be used in a real program so it is not pub. For +/// real programs, use `entries` which has properly typed, structured data. +const RawEntry = struct { key.Key, u21, u8, bool }; + +/// The raw data for how to map keys to Kitty data. Based on the information: +/// https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions +/// And the exact table is ported from Foot: +/// https://codeberg.org/dnkl/foot/src/branch/master/kitty-keymap.h +/// +/// Note that we currently don't support all the same keysyms as Kitty, +/// but we can add them as we add support. +/// +/// These are stored in order of the above Kitty link, so it is easy to +/// find the entry for a given key. +const raw_entries: []const RawEntry = &.{ + .{ .escape, 27, 'u', false }, + .{ .enter, 13, 'u', false }, + .{ .tab, 9, 'u', false }, + .{ .backspace, 127, 'u', false }, + .{ .insert, 2, '~', false }, + .{ .delete, 3, '~', false }, + .{ .arrow_left, 1, 'D', false }, + .{ .arrow_right, 1, 'C', false }, + .{ .arrow_up, 1, 'A', false }, + .{ .arrow_down, 1, 'B', false }, + .{ .page_up, 5, '~', false }, + .{ .page_down, 6, '~', false }, + .{ .home, 1, 'H', false }, + .{ .end, 1, 'F', false }, + .{ .caps_lock, 57358, 'u', true }, + .{ .scroll_lock, 57359, 'u', false }, + .{ .num_lock, 57360, 'u', true }, + .{ .print_screen, 57361, 'u', false }, + .{ .pause, 57362, 'u', false }, + + .{ .f1, 1, 'P', false }, + .{ .f2, 1, 'Q', false }, + .{ .f3, 13, '~', false }, + .{ .f4, 1, 'S', false }, + .{ .f5, 15, '~', false }, + .{ .f6, 17, '~', false }, + .{ .f7, 18, '~', false }, + .{ .f8, 19, '~', false }, + .{ .f9, 20, '~', false }, + .{ .f10, 21, '~', false }, + .{ .f11, 23, '~', false }, + .{ .f12, 24, '~', false }, + .{ .f13, 57376, 'u', false }, + .{ .f14, 57377, 'u', false }, + .{ .f15, 57378, 'u', false }, + .{ .f16, 57379, 'u', false }, + .{ .f17, 57380, 'u', false }, + .{ .f18, 57381, 'u', false }, + .{ .f19, 57382, 'u', false }, + .{ .f20, 57383, 'u', false }, + .{ .f21, 57384, 'u', false }, + .{ .f22, 57385, 'u', false }, + .{ .f23, 57386, 'u', false }, + .{ .f24, 57387, 'u', false }, + .{ .f25, 57388, 'u', false }, + + .{ .numpad_0, 57399, 'u', false }, + .{ .numpad_1, 57400, 'u', false }, + .{ .numpad_2, 57401, 'u', false }, + .{ .numpad_3, 57402, 'u', false }, + .{ .numpad_4, 57403, 'u', false }, + .{ .numpad_5, 57404, 'u', false }, + .{ .numpad_6, 57405, 'u', false }, + .{ .numpad_7, 57406, 'u', false }, + .{ .numpad_8, 57407, 'u', false }, + .{ .numpad_9, 57408, 'u', false }, + .{ .numpad_decimal, 57409, 'u', false }, + .{ .numpad_divide, 57410, 'u', false }, + .{ .numpad_multiply, 57411, 'u', false }, + .{ .numpad_subtract, 57412, 'u', false }, + .{ .numpad_add, 57413, 'u', false }, + .{ .numpad_enter, 57414, 'u', false }, + .{ .numpad_equal, 57415, 'u', false }, + .{ .numpad_separator, 57416, 'u', false }, + .{ .numpad_left, 57417, 'u', false }, + .{ .numpad_right, 57418, 'u', false }, + .{ .numpad_up, 57419, 'u', false }, + .{ .numpad_down, 57420, 'u', false }, + .{ .numpad_page_up, 57421, 'u', false }, + .{ .numpad_page_down, 57422, 'u', false }, + .{ .numpad_home, 57423, 'u', false }, + .{ .numpad_end, 57424, 'u', false }, + .{ .numpad_insert, 57425, 'u', false }, + .{ .numpad_delete, 57426, 'u', false }, + .{ .numpad_begin, 57427, 'u', false }, + + .{ .shift_left, 57441, 'u', true }, + .{ .shift_right, 57447, 'u', true }, + .{ .control_left, 57442, 'u', true }, + .{ .control_right, 57448, 'u', true }, + .{ .meta_left, 57444, 'u', true }, + .{ .meta_right, 57450, 'u', true }, + .{ .alt_left, 57443, 'u', true }, + .{ .alt_right, 57449, 'u', true }, +}; + +test { + // To force comptime to test it + _ = entries; +} diff --git a/vendor/libghostty-vt/src/input/mouse.zig b/vendor/libghostty-vt/src/input/mouse.zig new file mode 100644 index 00000000..fad3ed67 --- /dev/null +++ b/vendor/libghostty-vt/src/input/mouse.zig @@ -0,0 +1,110 @@ +const std = @import("std"); + +/// The type of action associated with a mouse event. This is different +/// from ButtonState because button state is simply the current state +/// of a mouse button but an action is something that triggers via +/// an GUI event and supports more. +pub const Action = enum(c_int) { press, release, motion }; + +/// The state of a mouse button. +/// +/// This is backed by a c_int so we can use this as-is for our embedding API. +/// +/// IMPORTANT: Any changes here update include/ghostty.h +pub const ButtonState = enum(c_int) { + release, + press, +}; + +/// Possible mouse buttons. We only track up to 11 because that's the maximum +/// button input that terminal mouse tracking handles without becoming +/// ambiguous. +/// +/// Its a bit silly to name numbers like this but given its a restricted +/// set, it feels better than passing around raw numeric literals. +/// +/// This is backed by a c_int so we can use this as-is for our embedding API. +/// +/// IMPORTANT: Any changes here update include/ghostty.h +pub const Button = enum(c_int) { + const Self = @This(); + + /// The maximum value in this enum. This can be used to create a densely + /// packed array, for example. + pub const max = max: { + var cur = 0; + for (@typeInfo(Self).@"enum".fields) |field| { + if (field.value > cur) cur = field.value; + } + + break :max cur; + }; + + unknown = 0, + left = 1, + right = 2, + middle = 3, + four = 4, + five = 5, + six = 6, + seven = 7, + eight = 8, + nine = 9, + ten = 10, + eleven = 11, +}; + +/// The "momentum" of a mouse scroll event. This matches the macOS events +/// because it is the only reliable source right now of momentum events. +/// This is used to handle "inertial scrolling" (i.e. flicking). +/// +/// https://developer.apple.com/documentation/appkit/nseventphase +pub const Momentum = enum(u3) { + none = 0, + began = 1, + stationary = 2, + changed = 3, + ended = 4, + cancelled = 5, + may_begin = 6, +}; + +/// The pressure stage of a pressure-sensitive input device. +/// +/// This currently only supports the stages that macOS supports. +pub const PressureStage = enum(u2) { + /// The input device is unpressed. + none = 0, + + /// The input device is pressed a normal amount. On macOS trackpads, + /// this is after a "click". + normal = 1, + + /// The input device is pressed a deep amount. On macOS trackpads, + /// this is after a "force click". + deep = 2, +}; + +/// The bitmask for mods for scroll events. +pub const ScrollMods = packed struct(u8) { + /// True if this is a high-precision scroll event. For example, Apple + /// devices such as Magic Mouse, trackpads, etc. are high-precision + /// and send very detailed scroll events. + precision: bool = false, + + /// The momentum phase (if available, supported) of the scroll event. + /// This is used to handle "inertial scrolling" (i.e. flicking). + momentum: Momentum = .none, + + _padding: u4 = 0, + + // For our own understanding + test { + const testing = std.testing; + try testing.expectEqual(@as(u8, @bitCast(ScrollMods{})), @as(u8, 0b0)); + try testing.expectEqual( + @as(u8, @bitCast(ScrollMods{ .precision = true })), + @as(u8, 0b0000_0001), + ); + } +}; diff --git a/vendor/libghostty-vt/src/input/mouse_encode.zig b/vendor/libghostty-vt/src/input/mouse_encode.zig new file mode 100644 index 00000000..13a8bd46 --- /dev/null +++ b/vendor/libghostty-vt/src/input/mouse_encode.zig @@ -0,0 +1,781 @@ +const std = @import("std"); +const testing = std.testing; +const terminal = @import("../terminal/main.zig"); +const Terminal = terminal.Terminal; +const renderer_size = @import("../renderer/size.zig"); +const point = @import("../terminal/point.zig"); +const key = @import("key.zig"); +const mouse = @import("mouse.zig"); + +const log = std.log.scoped(.mouse_encode); + +/// Options that affect mouse encoding behavior and provide runtime context. +pub const Options = struct { + /// Terminal mouse reporting mode (X10, normal, button, any). + event: terminal.MouseEvent = .none, + + /// Terminal mouse reporting format. + format: terminal.MouseFormat = .x10, + + /// Full renderer size used to convert surface-space pixel positions + /// into grid cell coordinates (for most formats) and terminal-space + /// pixel coordinates (for SGR-Pixels), as well as to determine + /// whether a position falls outside the visible viewport. + size: renderer_size.Size, + + /// Whether any mouse button is currently pressed. When a motion + /// event occurs outside the viewport, it is only reported if a + /// button is held down and the event mode supports motion tracking. + /// Without this, out-of-viewport motions are silently dropped. + /// + /// This should reflect the state of the current event as well, so + /// if the encoded event is a button press, this should be true. + any_button_pressed: bool = false, + + /// Last reported viewport cell for motion deduplication. + /// If null, motion deduplication state is not tracked. + last_cell: ?*?point.Coordinate = null, + + /// Initialize from terminal and renderer state. The caller may still + /// set any_button_pressed and last_cell on the returned value. + pub fn fromTerminal( + t: *const Terminal, + size: renderer_size.Size, + ) Options { + return .{ + .event = t.flags.mouse_event, + .format = t.flags.mouse_format, + .size = size, + }; + } +}; + +/// A normalized mouse event for protocol encoding. +pub const Event = struct { + /// The action of this mouse event. + action: mouse.Action = .press, + + /// The button involved in this event. This can be null in the + /// case of a motion action with no pressed buttons. + button: ?mouse.Button = null, + + /// Keyboard modifiers held during this event. + mods: key.Mods = .{}, + + /// Mouse position in terminal-space pixels, with (0, 0) at the top-left + /// of the terminal. Negative values are allowed and indicate positions + /// above or to the left of the terminal. Values larger than the terminal + /// size are also allowed and indicate right or below the terminal. + pos: Pos = .{}, + + /// Mouse position in surface-space pixels. + pub const Pos = extern struct { + x: f32 = 0, + y: f32 = 0, + }; +}; + +/// Encode the mouse event to the writer according to the options. +/// +/// Not all events result in output. +pub fn encode( + writer: *std.Io.Writer, + event: Event, + opts: Options, +) std.Io.Writer.Error!void { + if (!shouldReport(event, opts)) return; + + // Handle scenarios where the mouse position is outside the viewport. + // We always report release events no matter where they happen. + if (event.action != .release and + posOutOfViewport(event.pos, opts.size)) + { + // If we don't have a motion-tracking event mode, do nothing, + // because events outside the viewport are never reported in + // such cases. + if (!terminal.mouse.eventSendsMotion(opts.event)) return; + + // For motion modes, we only report if a button is currently pressed. + // This lets a TUI detect a click over the surface + drag out + // of the surface. + if (!opts.any_button_pressed) return; + } + + const cell = posToCell(event.pos, opts.size); + + // We only send motion events when the cell changed unless + // we're tracking raw pixels. + if (event.action == .motion and opts.format != .sgr_pixels) { + if (opts.last_cell) |last| { + if (last.*) |last_cell| { + if (last_cell.eql(cell)) return; + } + } + } + + // Update the last reported cell if we are tracking it. + if (opts.last_cell) |last| last.* = cell; + + const button_code = buttonCode(event, opts) orelse return; + switch (opts.format) { + .x10 => { + if (cell.x > 222 or cell.y > 222) { + log.info("X10 mouse format can only encode X/Y up to 223", .{}); + return; + } + + // + 1 because our x/y are zero-indexed and the protocol uses 1-indexing. + try writer.writeAll("\x1B[M"); + try writer.writeByte(32 + button_code); + try writer.writeByte(32 + @as(u8, @intCast(cell.x)) + 1); + try writer.writeByte(32 + @as(u8, @intCast(cell.y)) + 1); + }, + + .utf8 => { + try writer.writeAll("\x1B[M"); + + // The button code always fits in a single byte. + try writer.writeByte(32 + button_code); + + var buf: [4]u8 = undefined; + const x_cp: u21 = @intCast(@as(u32, cell.x) + 33); + const y_cp: u21 = @intCast(cell.y + 33); + + const x_len = std.unicode.utf8Encode(x_cp, &buf) catch unreachable; + try writer.writeAll(buf[0..x_len]); + + const y_len = std.unicode.utf8Encode(y_cp, &buf) catch unreachable; + try writer.writeAll(buf[0..y_len]); + }, + + .sgr => try writer.print("\x1B[<{d};{d};{d}{c}", .{ + button_code, + cell.x + 1, + cell.y + 1, + @as(u8, if (event.action == .release) 'm' else 'M'), + }), + + .urxvt => try writer.print("\x1B[{d};{d};{d}M", .{ + 32 + button_code, + cell.x + 1, + cell.y + 1, + }), + + .sgr_pixels => { + const pixels = posToPixels(event.pos, opts.size); + try writer.print("\x1B[<{d};{d};{d}{c}", .{ + button_code, + pixels.x, + pixels.y, + @as(u8, if (event.action == .release) 'm' else 'M'), + }); + }, + } +} + +/// Returns true if this event should be reported for the given mouse +/// event mode. +fn shouldReport(event: Event, opts: Options) bool { + return switch (opts.event) { + .none => false, + + // X10 only reports button presses of left, middle, and right. + .x10 => event.action == .press and + event.button != null and + (event.button.? == .left or + event.button.? == .middle or + event.button.? == .right), + + // Normal mode does not report motion. + .normal => event.action != .motion, + + // Button mode requires an active button for motion events. + .button => event.button != null, + + // Any mode reports everything. + .any => true, + }; +} + +fn buttonCode(event: Event, opts: Options) ?u8 { + var acc: u8 = code: { + if (event.button == null) { + // Null button means motion with no pressed button. + break :code 3; + } + + if (event.action == .release and + opts.format != .sgr and + opts.format != .sgr_pixels) + { + // Legacy releases are always encoded as button 3. + break :code 3; + } + + break :code switch (event.button.?) { + .left => 0, + .middle => 1, + .right => 2, + .four => 64, + .five => 65, + .six => 66, + .seven => 67, + .eight => 128, + .nine => 129, + else => return null, + }; + }; + + // X10 does not include modifiers. + if (opts.event != .x10) { + if (event.mods.shift) acc += 4; + if (event.mods.alt) acc += 8; + if (event.mods.ctrl) acc += 16; + } + + // Motion adds another bit. + if (event.action == .motion) acc += 32; + + return acc; +} + +/// Terminal-space pixel position for SGR pixel reporting. +const PixelPoint = struct { + x: i32, + y: i32, +}; + +/// Returns true if the surface-space pixel position is outside the +/// visible viewport bounds (negative or beyond screen dimensions). +fn posOutOfViewport(pos: Event.Pos, size: renderer_size.Size) bool { + const max_x: f32 = @floatFromInt(size.screen.width); + const max_y: f32 = @floatFromInt(size.screen.height); + return pos.x < 0 or pos.y < 0 or pos.x > max_x or pos.y > max_y; +} + +/// Converts a surface-space pixel position to a zero-based grid cell +/// coordinate (column, row) within the terminal viewport. Out-of-bounds +/// values are clamped to the valid grid range (0 to columns/rows - 1). +fn posToCell(pos: Event.Pos, size: renderer_size.Size) point.Coordinate { + const coord: renderer_size.Coordinate = .{ .surface = .{ + .x = @as(f64, @floatCast(pos.x)), + .y = @as(f64, @floatCast(pos.y)), + } }; + const grid = coord.convert(.grid, size).grid; + return .{ .x = grid.x, .y = grid.y }; +} + +/// Converts a surface-space pixel position to terminal-space pixel +/// coordinates (accounting for padding/scaling) used by SGR-Pixels mode. +/// Unlike grid conversion, terminal-space coordinates are not clamped +/// and may be negative or exceed the terminal dimensions. +fn posToPixels(pos: Event.Pos, size: renderer_size.Size) PixelPoint { + const coord: renderer_size.Coordinate.Terminal = (renderer_size.Coordinate{ .surface = .{ + .x = @as(f64, @floatCast(pos.x)), + .y = @as(f64, @floatCast(pos.y)), + } }).convert(.terminal, size).terminal; + + return .{ + .x = @as(i32, @intFromFloat(@round(coord.x))), + .y = @as(i32, @intFromFloat(@round(coord.y))), + }; +} + +fn testSize() renderer_size.Size { + return .{ + .screen = .{ .width = 1_000, .height = 1_000 }, + .cell = .{ .width = 1, .height = 1 }, + .padding = .{}, + }; +} + +test "shouldReport: none mode never reports" { + const size = testSize(); + inline for ([_]mouse.Action{ .press, .release, .motion }) |action| { + try testing.expect(!shouldReport(.{ + .button = .left, + .action = action, + }, .{ .event = .none, .size = size })); + } +} + +test "shouldReport: x10 reports only left/middle/right press" { + const size = testSize(); + // Left, middle, right presses should report. + inline for ([_]mouse.Button{ .left, .middle, .right }) |btn| { + try testing.expect(shouldReport(.{ + .button = btn, + .action = .press, + }, .{ .event = .x10, .size = size })); + } + + // Release is not reported. + try testing.expect(!shouldReport(.{ + .button = .left, + .action = .release, + }, .{ .event = .x10, .size = size })); + + // Motion is not reported. + try testing.expect(!shouldReport(.{ + .button = .left, + .action = .motion, + }, .{ .event = .x10, .size = size })); + + // Other buttons are not reported. + try testing.expect(!shouldReport(.{ + .button = .four, + .action = .press, + }, .{ .event = .x10, .size = size })); + + // Null button is not reported. + try testing.expect(!shouldReport(.{ + .button = null, + .action = .press, + }, .{ .event = .x10, .size = size })); +} + +test "shouldReport: normal reports press and release but not motion" { + const size = testSize(); + try testing.expect(shouldReport(.{ + .button = .left, + .action = .press, + }, .{ .event = .normal, .size = size })); + + try testing.expect(shouldReport(.{ + .button = .left, + .action = .release, + }, .{ .event = .normal, .size = size })); + + try testing.expect(!shouldReport(.{ + .button = .left, + .action = .motion, + }, .{ .event = .normal, .size = size })); +} + +test "shouldReport: button mode requires a button" { + const size = testSize(); + // With a button, all actions report. + inline for ([_]mouse.Action{ .press, .release, .motion }) |action| { + try testing.expect(shouldReport(.{ + .button = .left, + .action = action, + }, .{ .event = .button, .size = size })); + } + + // Without a button (null), nothing reports. + inline for ([_]mouse.Action{ .press, .release, .motion }) |action| { + try testing.expect(!shouldReport(.{ + .button = null, + .action = action, + }, .{ .event = .button, .size = size })); + } +} + +test "shouldReport: any mode reports everything" { + const size = testSize(); + inline for ([_]mouse.Action{ .press, .release, .motion }) |action| { + try testing.expect(shouldReport(.{ + .button = .left, + .action = action, + }, .{ .event = .any, .size = size })); + } + + // Even null button + motion reports. + try testing.expect(shouldReport(.{ + .button = null, + .action = .motion, + }, .{ .event = .any, .size = size })); +} + +test "x10 press left" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .mods = .{ .shift = true, .alt = true, .ctrl = true }, + .pos = .{ .x = 0, .y = 0 }, + }, .{ + .event = .x10, + .format = .x10, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualSlices(u8, &.{ + 0x1B, + '[', + 'M', + 32, + 33, + 33, + }, writer.buffered()); +} + +test "x10 ignores release" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .release, + }, .{ + .event = .x10, + .format = .x10, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "normal ignores motion" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .motion, + }, .{ + .event = .normal, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "button mode requires button" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = null, + .action = .motion, + }, .{ + .event = .button, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "sgr release keeps button identity" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .right, + .action = .release, + .pos = .{ .x = 4, .y = 5 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<2;5;6m", writer.buffered()); +} + +test "sgr motion with no button" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = null, + .action = .motion, + .pos = .{ .x = 1, .y = 2 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<35;2;3M", writer.buffered()); +} + +test "urxvt with modifiers" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .mods = .{ .shift = true, .alt = true, .ctrl = true }, + .pos = .{ .x = 2, .y = 3 }, + }, .{ + .event = .any, + .format = .urxvt, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[60;3;4M", writer.buffered()); +} + +test "utf8 encodes large coordinates" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .pos = .{ .x = 300, .y = 400 }, + }, .{ + .event = .any, + .format = .utf8, + .size = testSize(), + .last_cell = &last, + }); + + const out = writer.buffered(); + try testing.expectEqualSlices(u8, &.{ 0x1B, '[', 'M', 32 }, out[0..4]); + + const view = try std.unicode.Utf8View.init(out[4..]); + var it = view.iterator(); + try testing.expectEqual(@as(u21, 333), it.nextCodepoint().?); + try testing.expectEqual(@as(u21, 433), it.nextCodepoint().?); + try testing.expectEqual(@as(?u21, null), it.nextCodepoint()); +} + +test "x10 coordinate limit" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .pos = .{ .x = 223, .y = 0 }, + }, .{ + .event = .x10, + .format = .x10, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "sgr wheel button mappings" { + const Case = struct { + button: mouse.Button, + code: u8, + }; + + inline for ([_]Case{ + .{ .button = .four, .code = 64 }, + .{ .button = .five, .code = 65 }, + .{ .button = .six, .code = 66 }, + .{ .button = .seven, .code = 67 }, + }) |c| { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = c.button, + .action = .press, + .pos = .{ .x = 0, .y = 0 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + var expected: [32]u8 = undefined; + const want = try std.fmt.bufPrint(&expected, "\x1B[<{d};1;1M", .{c.code}); + try testing.expectEqualStrings(want, writer.buffered()); + } +} + +test "urxvt release uses legacy button 3 encoding" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .right, + .action = .release, + .pos = .{ .x = 2, .y = 3 }, + }, .{ + .event = .any, + .format = .urxvt, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[35;3;4M", writer.buffered()); +} + +test "unsupported button is ignored" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .ten, + .action = .press, + .pos = .{ .x = 1, .y = 1 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "sgr pixels uses terminal-space cursor coordinates" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .pos = .{ .x = 10, .y = 20 }, + }, .{ + .event = .any, + .format = .sgr_pixels, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<0;10;20M", writer.buffered()); +} + +test "sgr pixels release keeps button identity" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .right, + .action = .release, + .pos = .{ .x = 10, .y = 20 }, + }, .{ + .event = .any, + .format = .sgr_pixels, + .size = testSize(), + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<2;10;20m", writer.buffered()); +} + +test "position exactly at viewport boundary is encoded in final cell" { + const size: renderer_size.Size = .{ + .screen = .{ .width = 10, .height = 10 }, + .cell = .{ .width = 2, .height = 2 }, + .padding = .{}, + }; + + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .press, + .pos = .{ .x = 10, .y = 10 }, + }, .{ + .event = .any, + .format = .sgr, + .size = size, + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<0;5;5M", writer.buffered()); +} + +test "outside viewport motion with no pressed button is ignored" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = -1, .y = -1 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .any_button_pressed = false, + .last_cell = &last, + }); + + try testing.expectEqual(@as(usize, 0), writer.buffered().len); +} + +test "outside viewport motion with pressed button is reported" { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + var last: ?point.Coordinate = null; + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = -1, .y = -1 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .any_button_pressed = true, + .last_cell = &last, + }); + + try testing.expectEqualStrings("\x1B[<32;1;1M", writer.buffered()); +} + +test "motion is deduped by last cell except sgr pixels" { + var last: ?point.Coordinate = null; + + { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = 5, .y = 6 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + try testing.expect(writer.buffered().len > 0); + } + + { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = 5, .y = 6 }, + }, .{ + .event = .any, + .format = .sgr, + .size = testSize(), + .last_cell = &last, + }); + try testing.expectEqual(@as(usize, 0), writer.buffered().len); + } + + { + var data: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&data); + try encode(&writer, .{ + .button = .left, + .action = .motion, + .pos = .{ .x = 5, .y = 6 }, + }, .{ + .event = .any, + .format = .sgr_pixels, + .size = testSize(), + .last_cell = &last, + }); + try testing.expect(writer.buffered().len > 0); + } +} diff --git a/vendor/libghostty-vt/src/input/paste.zig b/vendor/libghostty-vt/src/input/paste.zig new file mode 100644 index 00000000..16b6266b --- /dev/null +++ b/vendor/libghostty-vt/src/input/paste.zig @@ -0,0 +1,228 @@ +const std = @import("std"); +const Terminal = @import("../terminal/Terminal.zig"); + +pub const Options = struct { + /// True if bracketed paste mode is on. + bracketed: bool, + + /// Return the encoding options based on the current terminal state. + pub fn fromTerminal(t: *const Terminal) Options { + return .{ + .bracketed = t.modes.get(.bracketed_paste), + }; + } +}; + +/// Encode the given data for pasting. The resulting value can be written +/// to the pty to perform a paste of the input data. +/// +/// The data can be either a `[]u8` or a `[]const u8`. If the data +/// type is const then `EncodeError` may be returned. If the data type +/// is mutable then this function can't return an error. +/// +/// This slightly complex calling style allows for initially const +/// data to be passed in without an allocation, since it is rare in normal +/// use cases that the data will need to be modified. In the unlikely case +/// data does need to be modified, the caller can make a mutable copy +/// after seeing the error. +/// +/// The data is returned as a set of slices to limit allocations. The caller +/// can combine the slices into a single buffer if desired. +/// +/// WARNING: The input data is not checked for safety. See the `isSafe` +/// function to check if the data is safe to paste. +pub fn encode( + data: anytype, + opts: Options, +) switch (@TypeOf(data)) { + []u8 => [3][]const u8, + []const u8 => Error![3][]const u8, + else => unreachable, +} { + // These are the set of byte values that are always replaced by + // a space (per xterm's behavior) for any text insertion method e.g. + // a paste, drag and drop, etc. These are copied directly from xterm's + // source. + const strip: []const u8 = &.{ + 0x00, // NUL + 0x08, // BS + 0x05, // ENQ + 0x04, // EOT + 0x1B, // ESC + 0x7F, // DEL + + // These can be overridden by the running terminal program + // via tcsetattr, so they aren't totally safe to hardcode like + // this. In practice, I haven't seen modern programs change these + // and its a much bigger architectural change to pass these through + // so for now they're hardcoded. + 0x03, // VINTR (Ctrl+C) + 0x1C, // VQUIT (Ctrl+\) + 0x15, // VKILL (Ctrl+U) + 0x1A, // VSUSP (Ctrl+Z) + 0x11, // VSTART (Ctrl+Q) + 0x13, // VSTOP (Ctrl+S) + 0x17, // VWERASE (Ctrl+W) + 0x16, // VLNEXT (Ctrl+V) + 0x12, // VREPRINT (Ctrl+R) + 0x0F, // VDISCARD (Ctrl+O) + }; + + const mutable = @TypeOf(data) == []u8; + + var result: [3][]const u8 = .{ "", data, "" }; + + // If we have any of the strip values, then we need to replace them + // with spaces. This is what xterm does and it does it regardless + // of bracketed paste mode. This is a security measure to prevent pastes + // from containing bytes that could be used to inject commands. + if (std.mem.indexOfAny(u8, data, strip) != null) { + if (comptime !mutable) return Error.MutableRequired; + var offset: usize = 0; + while (std.mem.indexOfAny( + u8, + data[offset..], + strip, + )) |idx| { + offset += idx; + data[offset] = ' '; + offset += 1; + } + } + + // Bracketed paste mode (mode 2004) wraps pasted data in + // fenceposts so that the terminal can ignore things like newlines. + if (opts.bracketed) { + result[0] = "\x1b[200~"; + result[2] = "\x1b[201~"; + return result; + } + + // Non-bracketed. We have to replace newline with `\r`. This matches + // the behavior of xterm and other terminals. For `\r\n` this will + // result in `\r\r` which does match xterm. + if (comptime mutable) { + std.mem.replaceScalar(u8, data, '\n', '\r'); + } else if (std.mem.indexOfScalar(u8, data, '\n') != null) { + return Error.MutableRequired; + } + + return result; +} + +pub const Error = error{ + /// Returned if encoding requires a mutable copy of the data. This + /// can only be returned if the input data type is const. + MutableRequired, +}; + +/// Returns true if the data looks safe to paste. Data is considered +/// unsafe if it contains any of the following: +/// +/// - `\n`: Newlines can be used to inject commands. +/// - `\x1b[201~`: This is the end of a bracketed paste. This cane be used +/// to exit a bracketed paste and inject commands. +/// +/// We consider any scenario unsafe regardless of current terminal state. +/// For example, even if bracketed paste mode is not active, we still +/// consider `\x1b[201~` unsafe. The existence of these types of bytes +/// should raise suspicion that the producer of the paste data is +/// acting strangely. +pub fn isSafe(data: []const u8) bool { + return std.mem.indexOf(u8, data, "\n") == null and + std.mem.indexOf(u8, data, "\x1b[201~") == null; +} + +test isSafe { + const testing = std.testing; + try testing.expect(isSafe("hello")); + try testing.expect(!isSafe("hello\n")); + try testing.expect(!isSafe("hello\nworld")); + try testing.expect(!isSafe("he\x1b[201~llo")); +} + +test "encode bracketed" { + const testing = std.testing; + const result = try encode( + @as([]const u8, "hello"), + .{ .bracketed = true }, + ); + try testing.expectEqualStrings("\x1b[200~", result[0]); + try testing.expectEqualStrings("hello", result[1]); + try testing.expectEqualStrings("\x1b[201~", result[2]); +} + +test "encode unbracketed no newlines" { + const testing = std.testing; + const result = try encode( + @as([]const u8, "hello"), + .{ .bracketed = false }, + ); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello", result[1]); + try testing.expectEqualStrings("", result[2]); +} + +test "encode unbracketed newlines const" { + const testing = std.testing; + try testing.expectError(Error.MutableRequired, encode( + @as([]const u8, "hello\nworld"), + .{ .bracketed = false }, + )); +} + +test "encode unbracketed newlines" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hello\nworld"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = false }); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello\rworld", result[1]); + try testing.expectEqualStrings("", result[2]); +} + +test "encode unbracketed windows-stye newline" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hello\r\nworld"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = false }); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello\r\rworld", result[1]); + try testing.expectEqualStrings("", result[2]); +} + +test "encode strip unsafe bytes const" { + const testing = std.testing; + try testing.expectError(Error.MutableRequired, encode( + @as([]const u8, "hello\x00world"), + .{ .bracketed = true }, + )); +} + +test "encode strip unsafe bytes mutable bracketed" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hel\x1blo\x00world"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = true }); + try testing.expectEqualStrings("\x1b[200~", result[0]); + try testing.expectEqualStrings("hel lo world", result[1]); + try testing.expectEqualStrings("\x1b[201~", result[2]); +} + +test "encode strip unsafe bytes mutable unbracketed" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hel\x03lo"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = false }); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hel lo", result[1]); + try testing.expectEqualStrings("", result[2]); +} + +test "encode strip multiple unsafe bytes" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "\x00\x08\x7f"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = true }); + try testing.expectEqualStrings(" ", result[1]); +} |
