diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/input/Binding.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/input/Binding.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/input/Binding.zig | 4847 |
1 files changed, 4847 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()); +} |
