aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/input
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/input
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/input')
-rw-r--r--vendor/libghostty-vt/src/input/Binding.zig4847
-rw-r--r--vendor/libghostty-vt/src/input/KeymapDarwin.zig289
-rw-r--r--vendor/libghostty-vt/src/input/KeymapNoop.zig39
-rw-r--r--vendor/libghostty-vt/src/input/Link.zig79
-rw-r--r--vendor/libghostty-vt/src/input/command.zig739
-rw-r--r--vendor/libghostty-vt/src/input/config.zig8
-rw-r--r--vendor/libghostty-vt/src/input/function_keys.zig312
-rw-r--r--vendor/libghostty-vt/src/input/helpgen_actions.zig112
-rw-r--r--vendor/libghostty-vt/src/input/key.zig878
-rw-r--r--vendor/libghostty-vt/src/input/key_encode.zig2424
-rw-r--r--vendor/libghostty-vt/src/input/key_mods.zig914
-rw-r--r--vendor/libghostty-vt/src/input/keyboard.zig58
-rw-r--r--vendor/libghostty-vt/src/input/keycodes.zig673
-rw-r--r--vendor/libghostty-vt/src/input/kitty.zig134
-rw-r--r--vendor/libghostty-vt/src/input/mouse.zig110
-rw-r--r--vendor/libghostty-vt/src/input/mouse_encode.zig781
-rw-r--r--vendor/libghostty-vt/src/input/paste.zig228
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]);
+}