From e03432241b8cd4065ec0aa21cf70988577d88f1a Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 21:56:07 +0200 Subject: [PATCH] feat(input): port keyboard, keybinding and mouse input handling Ports ZIG/src/input (except macOS keymaps) into ghostty-input: - key, key_mods (incl. RemapSet), the physical keycode table - key_encode: legacy, modifyOtherKeys and kitty keyboard protocol encoders, function key tables - Binding: all keybinding actions with their user-visible docs (for +list-actions), triggers, sets with sequences, chains, leader keys and key tables, and formatting back to config strings - command palette defaults, mouse, paste safety checks, kitty helpers, links, helpgen action docs Deviations (docs/rewrite/notes/ghostty-input.md): Mods.super is named super_; case folding uses char::to_lowercase plus Ghostty's special cases instead of uucode tables; chain parents are trigger paths instead of pointers; a Zig bug where chained leaves inside sequences corrupted the shared prefix when formatting is fixed (covered by a test). Integration left: building encode/paste Options from terminal modes, and implementing the EntryFormatter trait in ghostty-config. Test Plan: - cargo test -p ghostty-input (220 tests; 8 macOS-only skipped) - cargo clippy -p ghostty-input --all-targets -- -D warnings --- Cargo.lock | 1 + crates/ghostty-input/Cargo.toml | 2 + crates/ghostty-input/src/binding/action.rs | 3004 ++++++++++++++++++ crates/ghostty-input/src/binding/mod.rs | 787 +++++ crates/ghostty-input/src/binding/set.rs | 681 ++++ crates/ghostty-input/src/binding/tests.rs | 1560 +++++++++ crates/ghostty-input/src/command.rs | 590 ++++ crates/ghostty-input/src/config.rs | 13 + crates/ghostty-input/src/function_keys.rs | 616 ++++ crates/ghostty-input/src/helpgen_actions.rs | 148 + crates/ghostty-input/src/key.rs | 611 ++++ crates/ghostty-input/src/key_encode.rs | 3161 +++++++++++++++++++ crates/ghostty-input/src/key_mods.rs | 869 +++++ crates/ghostty-input/src/keyboard.rs | 56 + crates/ghostty-input/src/keycodes.rs | 697 ++++ crates/ghostty-input/src/kitty.rs | 134 + crates/ghostty-input/src/lib.rs | 27 +- crates/ghostty-input/src/link.rs | 63 + crates/ghostty-input/src/mouse.rs | 132 + crates/ghostty-input/src/paste.rs | 225 ++ docs/rewrite/notes/ghostty-input.md | 37 + 21 files changed, 13413 insertions(+), 1 deletion(-) create mode 100644 crates/ghostty-input/src/binding/action.rs create mode 100644 crates/ghostty-input/src/binding/mod.rs create mode 100644 crates/ghostty-input/src/binding/set.rs create mode 100644 crates/ghostty-input/src/binding/tests.rs create mode 100644 crates/ghostty-input/src/command.rs create mode 100644 crates/ghostty-input/src/config.rs create mode 100644 crates/ghostty-input/src/function_keys.rs create mode 100644 crates/ghostty-input/src/helpgen_actions.rs create mode 100644 crates/ghostty-input/src/key.rs create mode 100644 crates/ghostty-input/src/key_encode.rs create mode 100644 crates/ghostty-input/src/key_mods.rs create mode 100644 crates/ghostty-input/src/keyboard.rs create mode 100644 crates/ghostty-input/src/keycodes.rs create mode 100644 crates/ghostty-input/src/kitty.rs create mode 100644 crates/ghostty-input/src/link.rs create mode 100644 crates/ghostty-input/src/mouse.rs create mode 100644 crates/ghostty-input/src/paste.rs create mode 100644 docs/rewrite/notes/ghostty-input.md diff --git a/Cargo.lock b/Cargo.lock index 14d0fec..4efa674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -623,6 +623,7 @@ version = "0.1.0" dependencies = [ "base64", "log", + "memchr", "thiserror", ] diff --git a/crates/ghostty-input/Cargo.toml b/crates/ghostty-input/Cargo.toml index ffb0c18..c54e64e 100644 --- a/crates/ghostty-input/Cargo.toml +++ b/crates/ghostty-input/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true [dependencies] ghostty-vt.workspace = true +indexmap = "2.14.2" +thiserror = "2.0.21" [lints] workspace = true diff --git a/crates/ghostty-input/src/binding/action.rs b/crates/ghostty-input/src/binding/action.rs new file mode 100644 index 0000000..848f8ac --- /dev/null +++ b/crates/ghostty-input/src/binding/action.rs @@ -0,0 +1,3004 @@ +//! The set of actions that a keybinding can take (`Binding.Action` in +//! ZIG/src/input/Binding.zig). + +#![allow(clippy::needless_raw_string_hashes)] + +use std::fmt; +use std::hash::{Hash, Hasher}; + +use super::Error; + +/// Define a fieldless enum whose variants are named in snake_case in the +/// Zig source (and therefore in configuration / `+list-actions`). +macro_rules! str_enum { + ( + $(#[$meta:meta])* + pub enum $name:ident { + $($(#[$vmeta:meta])* $variant:ident => $s:literal,)* + } + ) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum $name { + $($(#[$vmeta])* $variant,)* + } + + impl $name { + /// The Zig tag name. + pub fn name(self) -> &'static str { + match self { + $($name::$variant => $s,)* + } + } + + /// Look up by Zig tag name. + pub fn from_name(s: &str) -> Option<$name> { + match s { + $($s => Some($name::$variant),)* + _ => None, + } + } + } + }; +} + +/// Implements `parse_param` for plain string enums. +macro_rules! plain_param { + ($name:ident) => { + impl $name { + pub(super) fn parse_param(s: &str) -> Result<$name, Error> { + $name::from_name(s).ok_or(Error::InvalidFormat) + } + } + }; +} + +/// The scope of an action. The scope is the context in which an action +/// must be executed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Scope { + App, + Surface, +} + +str_enum! { + pub enum CrashThread { + Main => "main", + Io => "io", + Render => "render", + } +} +plain_param!(CrashThread); + +/// Data to send depending on whether cursor key mode is enabled. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CursorKey { + pub normal: String, + pub application: String, +} + +str_enum! { + pub enum NavigateSearch { + Previous => "previous", + Next => "next", + } +} +plain_param!(NavigateSearch); + +str_enum! { + pub enum AdjustSelection { + Left => "left", + Right => "right", + Up => "up", + Down => "down", + PageUp => "page_up", + PageDown => "page_down", + Home => "home", + End => "end", + BeginningOfLine => "beginning_of_line", + EndOfLine => "end_of_line", + } +} +plain_param!(AdjustSelection); + +str_enum! { + pub enum SplitDirection { + Right => "right", + Down => "down", + Left => "left", + Up => "up", + /// splits along the larger direction + Auto => "auto", + } +} +plain_param!(SplitDirection); + +impl SplitDirection { + pub const DEFAULT: SplitDirection = SplitDirection::Auto; +} + +str_enum! { + pub enum SplitFocusDirection { + Previous => "previous", + Next => "next", + Up => "up", + Left => "left", + Down => "down", + Right => "right", + } +} + +impl SplitFocusDirection { + pub fn parse(input: &str) -> Result { + if let Some(v) = SplitFocusDirection::from_name(input) { + return Ok(v); + } + // For backwards compatibility we map "top" and "bottom" onto the enum + // values "up" and "down" + match input { + "top" => Ok(SplitFocusDirection::Up), + "bottom" => Ok(SplitFocusDirection::Down), + _ => Err(Error::InvalidFormat), + } + } + + pub(super) fn parse_param(s: &str) -> Result { + SplitFocusDirection::parse(s) + } +} + +str_enum! { + pub enum SplitResizeDirection { + Up => "up", + Down => "down", + Left => "left", + Right => "right", + } +} +plain_param!(SplitResizeDirection); + +str_enum! { + pub enum GotoWindow { + Previous => "previous", + Next => "next", + } +} +plain_param!(GotoWindow); + +/// Zig: a tuple `struct { SplitResizeDirection, u16 }`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SplitResizeParameter(pub SplitResizeDirection, pub u16); + +impl SplitResizeParameter { + pub(super) fn parse_param(param: &str) -> Result { + let mut it = param.split(','); + let dir = SplitResizeDirection::parse_param(it.next().ok_or(Error::InvalidFormat)?)?; + let amt = it + .next() + .ok_or(Error::InvalidFormat)? + .parse::() + .map_err(|_| Error::InvalidFormat)?; + // If we have extra parameters it is an error + if it.next().is_some() { + return Err(Error::InvalidFormat); + } + Ok(SplitResizeParameter(dir, amt)) + } + + pub(super) fn format_into(self, out: &mut String) { + out.push_str(self.0.name()); + out.push(','); + out.push_str(&self.1.to_string()); + } +} + +str_enum! { + pub enum CopyToClipboard { + Plain => "plain", + Vt => "vt", + Html => "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 => "mixed", + } +} +plain_param!(CopyToClipboard); + +impl CopyToClipboard { + pub const DEFAULT: CopyToClipboard = CopyToClipboard::Mixed; +} + +str_enum! { + pub enum WriteScreenAction { + Copy => "copy", + Paste => "paste", + Open => "open", + } +} + +str_enum! { + pub enum WriteScreenFormat { + Plain => "plain", + Vt => "vt", + Html => "html", + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct WriteScreen { + pub action: WriteScreenAction, + pub emit: WriteScreenFormat, +} + +impl WriteScreen { + pub const COPY: WriteScreen = WriteScreen { + action: WriteScreenAction::Copy, + emit: WriteScreenFormat::Plain, + }; + pub const PASTE: WriteScreen = WriteScreen { + action: WriteScreenAction::Paste, + emit: WriteScreenFormat::Plain, + }; + pub const OPEN: WriteScreen = WriteScreen { + action: WriteScreenAction::Open, + emit: WriteScreenFormat::Plain, + }; + + pub fn parse(param: &str) -> Result { + let pe = |s: &str| WriteScreenAction::from_name(s).ok_or(Error::InvalidFormat); + // 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. + let Some(idx) = param.find(',') else { + return Ok(WriteScreen { + action: pe(param)?, + emit: WriteScreenFormat::Plain, + }); + }; + Ok(WriteScreen { + action: pe(¶m[..idx])?, + emit: WriteScreenFormat::from_name(¶m[idx + 1..]).ok_or(Error::InvalidFormat)?, + }) + } + + pub(super) fn parse_param(s: &str) -> Result { + WriteScreen::parse(s) + } + + pub(super) fn format_into(self, out: &mut String) { + out.push_str(self.action.name()); + out.push(','); + out.push_str(self.emit.name()); + } +} + +str_enum! { + pub enum InspectorMode { + Toggle => "toggle", + Show => "show", + Hide => "hide", + } +} +plain_param!(InspectorMode); + +str_enum! { + pub enum CloseTabMode { + This => "this", + Other => "other", + Right => "right", + } +} +plain_param!(CloseTabMode); + +impl CloseTabMode { + pub const DEFAULT: CloseTabMode = CloseTabMode::This; +} + +/// Integer / float parameter parsing (`parseInt`/`parseFloat`). +pub(super) trait ParseNum: Sized { + fn parse_param(s: &str) -> Result; +} + +macro_rules! num_param { + ($($t:ty),*) => {$( + impl ParseNum for $t { + fn parse_param(s: &str) -> Result<$t, Error> { + s.parse::<$t>().map_err(|_| Error::InvalidFormat) + } + } + )*}; +} +num_param!(f32, usize, isize, i16); + +/// Escape in the style of Zig's `std.zig.stringEscape`. +pub(super) fn string_escape(s: &str, out: &mut String) { + use std::fmt::Write; + for &b in s.as_bytes() { + match b { + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + b'\\' => out.push_str("\\\\"), + b'"' => out.push_str("\\\""), + b'\'' | b' ' | b'!' | b'#'..=b'&' | b'('..=b'[' | b']'..=b'~' => out.push(b as char), + _ => { + let _ = write!(out, "\\x{b:02x}"); + } + } + } +} + +impl Eq for Action {} + +impl Hash for Action { + fn hash(&self, state: &mut H) { + self.hash_into(state); + } +} + +impl fmt::Display for Action { + /// Encodes the action back into the format used by `parse`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut s = String::new(); + self.fmt_into(&mut s); + f.write_str(&s) + } +} + +impl Action { + /// 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: &str) -> Result { + // 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. + let colon_idx = input.find(':'); + let action = &input[..colon_idx.unwrap_or(input.len())]; + + // An action name is always required + if action.is_empty() { + return Err(Error::InvalidFormat); + } + + Action::parse_named(action, colon_idx.map(|i| &input[i + 1..])) + } + + /// The action name (Zig `@tagName`). + pub fn name(&self) -> &'static str { + self.key().name() + } + + /// The doc comment of this action. + pub fn docs(&self) -> &'static str { + self.key().docs() + } + + /// A hash code that can be used to uniquely identify this action. + pub fn hash(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.hash_into(&mut h); + h.finish() + } + + /// Compares two actions for equality. + pub fn equal(&self, other: &Action) -> bool { + self == other + } + + /// Returns the action if it is of the given scope (Zig `scoped`). + /// + /// DEVIATION: Zig returns a distinct comptime-generated union type; here + /// the same `Action` type is returned. + pub fn scoped(&self, s: Scope) -> Option<&Action> { + (self.scope() == s).then_some(self) + } +} + +// @generated-once from ZIG/src/input/Binding.zig `Action` (see gen script in notes); now maintained by hand. + +/// The set of actions that a keybinding can take. +/// +/// The per-action documentation (printed by `ghostty +list-actions --docs`) +/// is available through [`Action::docs`] / [`ActionKey::docs`]. +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + // 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(String), + // Send an `ESC` sequence. + Esc(String), + // 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(String), + // Send data to the pty depending on whether cursor key mode is enabled + // (`application`) or disabled (`normal`). + CursorKey(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. + CopyToClipboard(CopyToClipboard), + // Paste the contents of the default clipboard. + PasteFromClipboard, + // Paste the contents of the selection clipboard. + PasteFromSelection, + // If there is a URL under the cursor, copy it to the default clipboard. + CopyUrlToClipboard, + // Copy the terminal title to the clipboard. If the terminal title is not + // set or is empty this has no effect. + CopyTitleToClipboard, + // 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. + IncreaseFontSize(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. + DecreaseFontSize(f32), + // Reset the font size to the original configured size. + ResetFontSize, + // 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. + SetFontSize(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(String), + // 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. + SearchSelection, + // Navigate the search results. If there is no active search, this + // is not performed. + NavigateSearch(NavigateSearch), + // Start a search if it isn't started already. This doesn't set any + // search terms, but opens the UI for searching. + StartSearch, + // End the current search if any and hide any GUI elements. + EndSearch, + // Clear the screen and all scrollback. + ClearScreen, + // Select all text on the screen. + SelectAll, + // Scroll to the top of the screen. + ScrollToTop, + // Scroll to the bottom of the screen. + ScrollToBottom, + // Scroll to the selected text. + ScrollToSelection, + // Scroll to the given absolute row in the screen with 0 being + // the first row. + ScrollToRow(usize), + // Scroll the screen up by one page. + ScrollPageUp, + // Scroll the screen down by one page. + ScrollPageDown, + // 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. + ScrollPageFractional(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. + ScrollPageLines(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. + // + AdjustSelection(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. + JumpToPrompt(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. + // + WriteScrollbackFile(WriteScreen), + // Write the contents of the screen into a temporary file with the + // specified action. + // + // See `write_scrollback_file` for possible actions. + WriteScreenFile(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. + WriteSelectionFile(WriteScreen), + // Open a new window. + // + // If the application isn't currently focused, + // this will bring it to the front. + NewWindow, + // Open a new tab. + NewTab, + // Go to the previous tab. + PreviousTab, + // Go to the next tab. + NextTab, + // Go to the last tab. + LastTab, + // 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. + GotoTab(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. + MoveTab(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`. + ToggleTabOverview, + // Change the title of the current focused surface via a pop-up prompt. + PromptSurfaceTitle, + // 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. + PromptTabTitle, + // Set the title for the current focused surface. + // + // If the title is empty, the surface title is reset to an empty title. + SetSurfaceTitle(String), + // Set the title for the current focused tab. + // + // If the title is empty, the tab title override is cleared. + SetTabTitle(String), + // 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. + // + NewSplit(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`). + GotoSplit(SplitFocusDirection), + // Focus on either the previous window or the next one ('previous', 'next') + GotoWindow(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. + ToggleSplitZoom, + // 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 + ToggleReadonly, + // 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`. + ResizeSplit(SplitResizeParameter), + // Equalize the size of all splits in the current window. + EqualizeSplits, + // 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. + ResetWindowSize, + // Control the visibility of the terminal inspector. + // + // Valid arguments: `toggle`, `show`, `hide`. + Inspector(InspectorMode), + // Show the GTK inspector. + // + // Has no effect on macOS. + ShowGtkInspector, + // 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. + ShowOnScreenKeyboard, + // 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. + OpenConfig, + // 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. + ReloadConfig, + // 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. + CloseSurface, + // 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. + CloseTab(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. + CloseWindow, + // 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. + CloseAllWindows, + // Maximize or unmaximize the current window. + // + // This has no effect on macOS as it does not have the concept of + // maximized windows. + ToggleMaximize, + // Fullscreen or unfullscreen the current window. + ToggleFullscreen, + // Toggle window decorations (titlebar, buttons, etc.) for the current window. + // + // Only implemented on Linux. + ToggleWindowDecorations, + // 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. + ToggleWindowFloatOnTop, + // 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. + ToggleSecureInput, + // 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. + ToggleMouseReporting, + // 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`. + ToggleCommandPalette, + // 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. + ToggleQuickTerminal, + // 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. + ToggleVisibility, + // 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. + ToggleBackgroundOpacity, + // Check for updates. + // + // Only implemented on macOS. + CheckForUpdates, + // 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. + EndKeySequence, + // 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. + ActivateKeyTable(String), + // 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. + ActivateKeyTableOnce(String), + // 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. + DeactivateKeyTable, + // Deactivate all active key tables. If no active key table exists, + // this will report performable as false. + DeactivateAllKeyTables, + // 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), +} + +/// The tag of an [`Action`] (Zig `Action.Key`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ActionKey { + Ignore, + Unbind, + Csi, + Esc, + Text, + CursorKey, + Reset, + CopyToClipboard, + PasteFromClipboard, + PasteFromSelection, + CopyUrlToClipboard, + CopyTitleToClipboard, + IncreaseFontSize, + DecreaseFontSize, + ResetFontSize, + SetFontSize, + Search, + SearchSelection, + NavigateSearch, + StartSearch, + EndSearch, + ClearScreen, + SelectAll, + ScrollToTop, + ScrollToBottom, + ScrollToSelection, + ScrollToRow, + ScrollPageUp, + ScrollPageDown, + ScrollPageFractional, + ScrollPageLines, + AdjustSelection, + JumpToPrompt, + WriteScrollbackFile, + WriteScreenFile, + WriteSelectionFile, + NewWindow, + NewTab, + PreviousTab, + NextTab, + LastTab, + GotoTab, + MoveTab, + ToggleTabOverview, + PromptSurfaceTitle, + PromptTabTitle, + SetSurfaceTitle, + SetTabTitle, + NewSplit, + GotoSplit, + GotoWindow, + ToggleSplitZoom, + ToggleReadonly, + ResizeSplit, + EqualizeSplits, + ResetWindowSize, + Inspector, + ShowGtkInspector, + ShowOnScreenKeyboard, + OpenConfig, + ReloadConfig, + CloseSurface, + CloseTab, + CloseWindow, + CloseAllWindows, + ToggleMaximize, + ToggleFullscreen, + ToggleWindowDecorations, + ToggleWindowFloatOnTop, + ToggleSecureInput, + ToggleMouseReporting, + ToggleCommandPalette, + ToggleQuickTerminal, + ToggleVisibility, + ToggleBackgroundOpacity, + CheckForUpdates, + Undo, + Redo, + EndKeySequence, + ActivateKeyTable, + ActivateKeyTableOnce, + DeactivateKeyTable, + DeactivateAllKeyTables, + Quit, + Crash, +} + +impl ActionKey { + /// All action keys in declaration order. + pub const ALL: &'static [ActionKey] = &[ + ActionKey::Ignore, + ActionKey::Unbind, + ActionKey::Csi, + ActionKey::Esc, + ActionKey::Text, + ActionKey::CursorKey, + ActionKey::Reset, + ActionKey::CopyToClipboard, + ActionKey::PasteFromClipboard, + ActionKey::PasteFromSelection, + ActionKey::CopyUrlToClipboard, + ActionKey::CopyTitleToClipboard, + ActionKey::IncreaseFontSize, + ActionKey::DecreaseFontSize, + ActionKey::ResetFontSize, + ActionKey::SetFontSize, + ActionKey::Search, + ActionKey::SearchSelection, + ActionKey::NavigateSearch, + ActionKey::StartSearch, + ActionKey::EndSearch, + ActionKey::ClearScreen, + ActionKey::SelectAll, + ActionKey::ScrollToTop, + ActionKey::ScrollToBottom, + ActionKey::ScrollToSelection, + ActionKey::ScrollToRow, + ActionKey::ScrollPageUp, + ActionKey::ScrollPageDown, + ActionKey::ScrollPageFractional, + ActionKey::ScrollPageLines, + ActionKey::AdjustSelection, + ActionKey::JumpToPrompt, + ActionKey::WriteScrollbackFile, + ActionKey::WriteScreenFile, + ActionKey::WriteSelectionFile, + ActionKey::NewWindow, + ActionKey::NewTab, + ActionKey::PreviousTab, + ActionKey::NextTab, + ActionKey::LastTab, + ActionKey::GotoTab, + ActionKey::MoveTab, + ActionKey::ToggleTabOverview, + ActionKey::PromptSurfaceTitle, + ActionKey::PromptTabTitle, + ActionKey::SetSurfaceTitle, + ActionKey::SetTabTitle, + ActionKey::NewSplit, + ActionKey::GotoSplit, + ActionKey::GotoWindow, + ActionKey::ToggleSplitZoom, + ActionKey::ToggleReadonly, + ActionKey::ResizeSplit, + ActionKey::EqualizeSplits, + ActionKey::ResetWindowSize, + ActionKey::Inspector, + ActionKey::ShowGtkInspector, + ActionKey::ShowOnScreenKeyboard, + ActionKey::OpenConfig, + ActionKey::ReloadConfig, + ActionKey::CloseSurface, + ActionKey::CloseTab, + ActionKey::CloseWindow, + ActionKey::CloseAllWindows, + ActionKey::ToggleMaximize, + ActionKey::ToggleFullscreen, + ActionKey::ToggleWindowDecorations, + ActionKey::ToggleWindowFloatOnTop, + ActionKey::ToggleSecureInput, + ActionKey::ToggleMouseReporting, + ActionKey::ToggleCommandPalette, + ActionKey::ToggleQuickTerminal, + ActionKey::ToggleVisibility, + ActionKey::ToggleBackgroundOpacity, + ActionKey::CheckForUpdates, + ActionKey::Undo, + ActionKey::Redo, + ActionKey::EndKeySequence, + ActionKey::ActivateKeyTable, + ActionKey::ActivateKeyTableOnce, + ActionKey::DeactivateKeyTable, + ActionKey::DeactivateAllKeyTables, + ActionKey::Quit, + ActionKey::Crash, + ]; + + /// The action name as used in keybind configuration. + pub fn name(self) -> &'static str { + match self { + ActionKey::Ignore => "ignore", + ActionKey::Unbind => "unbind", + ActionKey::Csi => "csi", + ActionKey::Esc => "esc", + ActionKey::Text => "text", + ActionKey::CursorKey => "cursor_key", + ActionKey::Reset => "reset", + ActionKey::CopyToClipboard => "copy_to_clipboard", + ActionKey::PasteFromClipboard => "paste_from_clipboard", + ActionKey::PasteFromSelection => "paste_from_selection", + ActionKey::CopyUrlToClipboard => "copy_url_to_clipboard", + ActionKey::CopyTitleToClipboard => "copy_title_to_clipboard", + ActionKey::IncreaseFontSize => "increase_font_size", + ActionKey::DecreaseFontSize => "decrease_font_size", + ActionKey::ResetFontSize => "reset_font_size", + ActionKey::SetFontSize => "set_font_size", + ActionKey::Search => "search", + ActionKey::SearchSelection => "search_selection", + ActionKey::NavigateSearch => "navigate_search", + ActionKey::StartSearch => "start_search", + ActionKey::EndSearch => "end_search", + ActionKey::ClearScreen => "clear_screen", + ActionKey::SelectAll => "select_all", + ActionKey::ScrollToTop => "scroll_to_top", + ActionKey::ScrollToBottom => "scroll_to_bottom", + ActionKey::ScrollToSelection => "scroll_to_selection", + ActionKey::ScrollToRow => "scroll_to_row", + ActionKey::ScrollPageUp => "scroll_page_up", + ActionKey::ScrollPageDown => "scroll_page_down", + ActionKey::ScrollPageFractional => "scroll_page_fractional", + ActionKey::ScrollPageLines => "scroll_page_lines", + ActionKey::AdjustSelection => "adjust_selection", + ActionKey::JumpToPrompt => "jump_to_prompt", + ActionKey::WriteScrollbackFile => "write_scrollback_file", + ActionKey::WriteScreenFile => "write_screen_file", + ActionKey::WriteSelectionFile => "write_selection_file", + ActionKey::NewWindow => "new_window", + ActionKey::NewTab => "new_tab", + ActionKey::PreviousTab => "previous_tab", + ActionKey::NextTab => "next_tab", + ActionKey::LastTab => "last_tab", + ActionKey::GotoTab => "goto_tab", + ActionKey::MoveTab => "move_tab", + ActionKey::ToggleTabOverview => "toggle_tab_overview", + ActionKey::PromptSurfaceTitle => "prompt_surface_title", + ActionKey::PromptTabTitle => "prompt_tab_title", + ActionKey::SetSurfaceTitle => "set_surface_title", + ActionKey::SetTabTitle => "set_tab_title", + ActionKey::NewSplit => "new_split", + ActionKey::GotoSplit => "goto_split", + ActionKey::GotoWindow => "goto_window", + ActionKey::ToggleSplitZoom => "toggle_split_zoom", + ActionKey::ToggleReadonly => "toggle_readonly", + ActionKey::ResizeSplit => "resize_split", + ActionKey::EqualizeSplits => "equalize_splits", + ActionKey::ResetWindowSize => "reset_window_size", + ActionKey::Inspector => "inspector", + ActionKey::ShowGtkInspector => "show_gtk_inspector", + ActionKey::ShowOnScreenKeyboard => "show_on_screen_keyboard", + ActionKey::OpenConfig => "open_config", + ActionKey::ReloadConfig => "reload_config", + ActionKey::CloseSurface => "close_surface", + ActionKey::CloseTab => "close_tab", + ActionKey::CloseWindow => "close_window", + ActionKey::CloseAllWindows => "close_all_windows", + ActionKey::ToggleMaximize => "toggle_maximize", + ActionKey::ToggleFullscreen => "toggle_fullscreen", + ActionKey::ToggleWindowDecorations => "toggle_window_decorations", + ActionKey::ToggleWindowFloatOnTop => "toggle_window_float_on_top", + ActionKey::ToggleSecureInput => "toggle_secure_input", + ActionKey::ToggleMouseReporting => "toggle_mouse_reporting", + ActionKey::ToggleCommandPalette => "toggle_command_palette", + ActionKey::ToggleQuickTerminal => "toggle_quick_terminal", + ActionKey::ToggleVisibility => "toggle_visibility", + ActionKey::ToggleBackgroundOpacity => "toggle_background_opacity", + ActionKey::CheckForUpdates => "check_for_updates", + ActionKey::Undo => "undo", + ActionKey::Redo => "redo", + ActionKey::EndKeySequence => "end_key_sequence", + ActionKey::ActivateKeyTable => "activate_key_table", + ActionKey::ActivateKeyTableOnce => "activate_key_table_once", + ActionKey::DeactivateKeyTable => "deactivate_key_table", + ActionKey::DeactivateAllKeyTables => "deactivate_all_key_tables", + ActionKey::Quit => "quit", + ActionKey::Crash => "crash", + } + } + + /// The doc comment of the action (Zig `help_strings.KeybindAction`). + pub fn docs(self) -> &'static str { + match self { + ActionKey::Ignore => { + r##"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. +"## + } + ActionKey::Unbind => { + r##"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). +"## + } + ActionKey::Csi => { + r##"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. +"## + } + ActionKey::Esc => { + r##"Send an `ESC` sequence. +"## + } + ActionKey::Text => { + r##"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. +"## + } + ActionKey::CursorKey => { + r##"Send data to the pty depending on whether cursor key mode is enabled +(`application`) or disabled (`normal`). +"## + } + ActionKey::Reset => { + r##"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. +"## + } + ActionKey::CopyToClipboard => { + r##"Copy the selected text to the clipboard. +"## + } + ActionKey::PasteFromClipboard => { + r##"Paste the contents of the default clipboard. +"## + } + ActionKey::PasteFromSelection => { + r##"Paste the contents of the selection clipboard. +"## + } + ActionKey::CopyUrlToClipboard => { + r##"If there is a URL under the cursor, copy it to the default clipboard. +"## + } + ActionKey::CopyTitleToClipboard => { + r##"Copy the terminal title to the clipboard. If the terminal title is not +set or is empty this has no effect. +"## + } + ActionKey::IncreaseFontSize => { + r##"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. +"## + } + ActionKey::DecreaseFontSize => { + r##"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. +"## + } + ActionKey::ResetFontSize => { + r##"Reset the font size to the original configured size. +"## + } + ActionKey::SetFontSize => { + r##"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. +"## + } + ActionKey::Search => { + r##"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. +"## + } + ActionKey::SearchSelection => { + r##"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. +"## + } + ActionKey::NavigateSearch => { + r##"Navigate the search results. If there is no active search, this +is not performed. +"## + } + ActionKey::StartSearch => { + r##"Start a search if it isn't started already. This doesn't set any +search terms, but opens the UI for searching. +"## + } + ActionKey::EndSearch => { + r##"End the current search if any and hide any GUI elements. +"## + } + ActionKey::ClearScreen => { + r##"Clear the screen and all scrollback. +"## + } + ActionKey::SelectAll => { + r##"Select all text on the screen. +"## + } + ActionKey::ScrollToTop => { + r##"Scroll to the top of the screen. +"## + } + ActionKey::ScrollToBottom => { + r##"Scroll to the bottom of the screen. +"## + } + ActionKey::ScrollToSelection => { + r##"Scroll to the selected text. +"## + } + ActionKey::ScrollToRow => { + r##"Scroll to the given absolute row in the screen with 0 being +the first row. +"## + } + ActionKey::ScrollPageUp => { + r##"Scroll the screen up by one page. +"## + } + ActionKey::ScrollPageDown => { + r##"Scroll the screen down by one page. +"## + } + ActionKey::ScrollPageFractional => { + r##"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. +"## + } + ActionKey::ScrollPageLines => { + r##"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. +"## + } + ActionKey::AdjustSelection => { + r##"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. + +"## + } + ActionKey::JumpToPrompt => { + r##"Jump the viewport forward or back by the given number of prompts. + +Requires shell integration. + +Positive values scroll downwards, and negative values scroll upwards. +"## + } + ActionKey::WriteScrollbackFile => { + r##"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. + +"## + } + ActionKey::WriteScreenFile => { + r##"Write the contents of the screen into a temporary file with the +specified action. + +See `write_scrollback_file` for possible actions. +"## + } + ActionKey::WriteSelectionFile => { + r##"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. +"## + } + ActionKey::NewWindow => { + r##"Open a new window. + +If the application isn't currently focused, +this will bring it to the front. +"## + } + ActionKey::NewTab => { + r##"Open a new tab. +"## + } + ActionKey::PreviousTab => { + r##"Go to the previous tab. +"## + } + ActionKey::NextTab => { + r##"Go to the next tab. +"## + } + ActionKey::LastTab => { + r##"Go to the last tab. +"## + } + ActionKey::GotoTab => { + r##"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. +"## + } + ActionKey::MoveTab => { + r##"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. +"## + } + ActionKey::ToggleTabOverview => { + r##"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`. +"## + } + ActionKey::PromptSurfaceTitle => { + r##"Change the title of the current focused surface via a pop-up prompt. +"## + } + ActionKey::PromptTabTitle => { + r##"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. +"## + } + ActionKey::SetSurfaceTitle => { + r##"Set the title for the current focused surface. + +If the title is empty, the surface title is reset to an empty title. +"## + } + ActionKey::SetTabTitle => { + r##"Set the title for the current focused tab. + +If the title is empty, the tab title override is cleared. +"## + } + ActionKey::NewSplit => { + r##"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. + +"## + } + ActionKey::GotoSplit => { + r##"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`). +"## + } + ActionKey::GotoWindow => { + r##"Focus on either the previous window or the next one ('previous', 'next') +"## + } + ActionKey::ToggleSplitZoom => { + r##"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. +"## + } + ActionKey::ToggleReadonly => { + r##"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 +"## + } + ActionKey::ResizeSplit => { + r##"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`. +"## + } + ActionKey::EqualizeSplits => { + r##"Equalize the size of all splits in the current window. +"## + } + ActionKey::ResetWindowSize => { + r##"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. +"## + } + ActionKey::Inspector => { + r##"Control the visibility of the terminal inspector. + +Valid arguments: `toggle`, `show`, `hide`. +"## + } + ActionKey::ShowGtkInspector => { + r##"Show the GTK inspector. + +Has no effect on macOS. +"## + } + ActionKey::ShowOnScreenKeyboard => { + r##"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. +"## + } + ActionKey::OpenConfig => { + r##"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. +"## + } + ActionKey::ReloadConfig => { + r##"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. +"## + } + ActionKey::CloseSurface => { + r##"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. +"## + } + ActionKey::CloseTab => { + r##"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. +"## + } + ActionKey::CloseWindow => { + r##"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. +"## + } + ActionKey::CloseAllWindows => { + r##"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. +"## + } + ActionKey::ToggleMaximize => { + r##"Maximize or unmaximize the current window. + +This has no effect on macOS as it does not have the concept of +maximized windows. +"## + } + ActionKey::ToggleFullscreen => { + r##"Fullscreen or unfullscreen the current window. +"## + } + ActionKey::ToggleWindowDecorations => { + r##"Toggle window decorations (titlebar, buttons, etc.) for the current window. + +Only implemented on Linux. +"## + } + ActionKey::ToggleWindowFloatOnTop => { + r##"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. +"## + } + ActionKey::ToggleSecureInput => { + r##"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. +"## + } + ActionKey::ToggleMouseReporting => { + r##"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. +"## + } + ActionKey::ToggleCommandPalette => { + r##"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`. +"## + } + ActionKey::ToggleQuickTerminal => { + r##"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. +"## + } + ActionKey::ToggleVisibility => { + r##"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. +"## + } + ActionKey::ToggleBackgroundOpacity => { + r##"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. +"## + } + ActionKey::CheckForUpdates => { + r##"Check for updates. + +Only implemented on macOS. +"## + } + ActionKey::Undo => { + r##"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. +"## + } + ActionKey::Redo => { + r##"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. +"## + } + ActionKey::EndKeySequence => { + r##"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. +"## + } + ActionKey::ActivateKeyTable => { + r##"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. +"## + } + ActionKey::ActivateKeyTableOnce => { + r##"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. +"## + } + ActionKey::DeactivateKeyTable => { + r##"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. +"## + } + ActionKey::DeactivateAllKeyTables => { + r##"Deactivate all active key tables. If no active key table exists, +this will report performable as false. +"## + } + ActionKey::Quit => { + r##"Quit Ghostty. +"## + } + ActionKey::Crash => { + r##"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. + +"## + } + } + } +} + +impl Action { + /// The tag of this action. + pub fn key(&self) -> ActionKey { + match self { + Action::Ignore => ActionKey::Ignore, + Action::Unbind => ActionKey::Unbind, + Action::Csi(_) => ActionKey::Csi, + Action::Esc(_) => ActionKey::Esc, + Action::Text(_) => ActionKey::Text, + Action::CursorKey(_) => ActionKey::CursorKey, + Action::Reset => ActionKey::Reset, + Action::CopyToClipboard(_) => ActionKey::CopyToClipboard, + Action::PasteFromClipboard => ActionKey::PasteFromClipboard, + Action::PasteFromSelection => ActionKey::PasteFromSelection, + Action::CopyUrlToClipboard => ActionKey::CopyUrlToClipboard, + Action::CopyTitleToClipboard => ActionKey::CopyTitleToClipboard, + Action::IncreaseFontSize(_) => ActionKey::IncreaseFontSize, + Action::DecreaseFontSize(_) => ActionKey::DecreaseFontSize, + Action::ResetFontSize => ActionKey::ResetFontSize, + Action::SetFontSize(_) => ActionKey::SetFontSize, + Action::Search(_) => ActionKey::Search, + Action::SearchSelection => ActionKey::SearchSelection, + Action::NavigateSearch(_) => ActionKey::NavigateSearch, + Action::StartSearch => ActionKey::StartSearch, + Action::EndSearch => ActionKey::EndSearch, + Action::ClearScreen => ActionKey::ClearScreen, + Action::SelectAll => ActionKey::SelectAll, + Action::ScrollToTop => ActionKey::ScrollToTop, + Action::ScrollToBottom => ActionKey::ScrollToBottom, + Action::ScrollToSelection => ActionKey::ScrollToSelection, + Action::ScrollToRow(_) => ActionKey::ScrollToRow, + Action::ScrollPageUp => ActionKey::ScrollPageUp, + Action::ScrollPageDown => ActionKey::ScrollPageDown, + Action::ScrollPageFractional(_) => ActionKey::ScrollPageFractional, + Action::ScrollPageLines(_) => ActionKey::ScrollPageLines, + Action::AdjustSelection(_) => ActionKey::AdjustSelection, + Action::JumpToPrompt(_) => ActionKey::JumpToPrompt, + Action::WriteScrollbackFile(_) => ActionKey::WriteScrollbackFile, + Action::WriteScreenFile(_) => ActionKey::WriteScreenFile, + Action::WriteSelectionFile(_) => ActionKey::WriteSelectionFile, + Action::NewWindow => ActionKey::NewWindow, + Action::NewTab => ActionKey::NewTab, + Action::PreviousTab => ActionKey::PreviousTab, + Action::NextTab => ActionKey::NextTab, + Action::LastTab => ActionKey::LastTab, + Action::GotoTab(_) => ActionKey::GotoTab, + Action::MoveTab(_) => ActionKey::MoveTab, + Action::ToggleTabOverview => ActionKey::ToggleTabOverview, + Action::PromptSurfaceTitle => ActionKey::PromptSurfaceTitle, + Action::PromptTabTitle => ActionKey::PromptTabTitle, + Action::SetSurfaceTitle(_) => ActionKey::SetSurfaceTitle, + Action::SetTabTitle(_) => ActionKey::SetTabTitle, + Action::NewSplit(_) => ActionKey::NewSplit, + Action::GotoSplit(_) => ActionKey::GotoSplit, + Action::GotoWindow(_) => ActionKey::GotoWindow, + Action::ToggleSplitZoom => ActionKey::ToggleSplitZoom, + Action::ToggleReadonly => ActionKey::ToggleReadonly, + Action::ResizeSplit(_) => ActionKey::ResizeSplit, + Action::EqualizeSplits => ActionKey::EqualizeSplits, + Action::ResetWindowSize => ActionKey::ResetWindowSize, + Action::Inspector(_) => ActionKey::Inspector, + Action::ShowGtkInspector => ActionKey::ShowGtkInspector, + Action::ShowOnScreenKeyboard => ActionKey::ShowOnScreenKeyboard, + Action::OpenConfig => ActionKey::OpenConfig, + Action::ReloadConfig => ActionKey::ReloadConfig, + Action::CloseSurface => ActionKey::CloseSurface, + Action::CloseTab(_) => ActionKey::CloseTab, + Action::CloseWindow => ActionKey::CloseWindow, + Action::CloseAllWindows => ActionKey::CloseAllWindows, + Action::ToggleMaximize => ActionKey::ToggleMaximize, + Action::ToggleFullscreen => ActionKey::ToggleFullscreen, + Action::ToggleWindowDecorations => ActionKey::ToggleWindowDecorations, + Action::ToggleWindowFloatOnTop => ActionKey::ToggleWindowFloatOnTop, + Action::ToggleSecureInput => ActionKey::ToggleSecureInput, + Action::ToggleMouseReporting => ActionKey::ToggleMouseReporting, + Action::ToggleCommandPalette => ActionKey::ToggleCommandPalette, + Action::ToggleQuickTerminal => ActionKey::ToggleQuickTerminal, + Action::ToggleVisibility => ActionKey::ToggleVisibility, + Action::ToggleBackgroundOpacity => ActionKey::ToggleBackgroundOpacity, + Action::CheckForUpdates => ActionKey::CheckForUpdates, + Action::Undo => ActionKey::Undo, + Action::Redo => ActionKey::Redo, + Action::EndKeySequence => ActionKey::EndKeySequence, + Action::ActivateKeyTable(_) => ActionKey::ActivateKeyTable, + Action::ActivateKeyTableOnce(_) => ActionKey::ActivateKeyTableOnce, + Action::DeactivateKeyTable => ActionKey::DeactivateKeyTable, + Action::DeactivateAllKeyTables => ActionKey::DeactivateAllKeyTables, + Action::Quit => ActionKey::Quit, + Action::Crash(_) => ActionKey::Crash, + } + } + + /// Parse the action given its name, the optional parameter (text after the first colon). + fn parse_named(name: &str, param: Option<&str>) -> Result { + match name { + "ignore" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::Ignore) + } + "unbind" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::Unbind) + } + "csi" => { + let p = param.ok_or(Error::InvalidFormat)?; + Ok(Action::Csi(p.to_string())) + } + "esc" => { + let p = param.ok_or(Error::InvalidFormat)?; + Ok(Action::Esc(p.to_string())) + } + "text" => { + let p = param.ok_or(Error::InvalidFormat)?; + Ok(Action::Text(p.to_string())) + } + "cursor_key" => Err(Error::InvalidAction), + "reset" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::Reset) + } + "copy_to_clipboard" => { + let Some(p) = param else { + return Ok(Action::CopyToClipboard(CopyToClipboard::DEFAULT)); + }; + Ok(Action::CopyToClipboard(CopyToClipboard::parse_param(p)?)) + } + "paste_from_clipboard" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::PasteFromClipboard) + } + "paste_from_selection" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::PasteFromSelection) + } + "copy_url_to_clipboard" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::CopyUrlToClipboard) + } + "copy_title_to_clipboard" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::CopyTitleToClipboard) + } + "increase_font_size" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::IncreaseFontSize(f32::parse_param(p)?)) + } + "decrease_font_size" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::DecreaseFontSize(f32::parse_param(p)?)) + } + "reset_font_size" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ResetFontSize) + } + "set_font_size" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::SetFontSize(f32::parse_param(p)?)) + } + "search" => { + let p = param.ok_or(Error::InvalidFormat)?; + Ok(Action::Search(p.to_string())) + } + "search_selection" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::SearchSelection) + } + "navigate_search" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::NavigateSearch(NavigateSearch::parse_param(p)?)) + } + "start_search" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::StartSearch) + } + "end_search" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::EndSearch) + } + "clear_screen" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ClearScreen) + } + "select_all" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::SelectAll) + } + "scroll_to_top" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ScrollToTop) + } + "scroll_to_bottom" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ScrollToBottom) + } + "scroll_to_selection" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ScrollToSelection) + } + "scroll_to_row" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::ScrollToRow(usize::parse_param(p)?)) + } + "scroll_page_up" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ScrollPageUp) + } + "scroll_page_down" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ScrollPageDown) + } + "scroll_page_fractional" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::ScrollPageFractional(f32::parse_param(p)?)) + } + "scroll_page_lines" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::ScrollPageLines(i16::parse_param(p)?)) + } + "adjust_selection" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::AdjustSelection(AdjustSelection::parse_param(p)?)) + } + "jump_to_prompt" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::JumpToPrompt(i16::parse_param(p)?)) + } + "write_scrollback_file" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::WriteScrollbackFile(WriteScreen::parse_param(p)?)) + } + "write_screen_file" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::WriteScreenFile(WriteScreen::parse_param(p)?)) + } + "write_selection_file" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::WriteSelectionFile(WriteScreen::parse_param(p)?)) + } + "new_window" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::NewWindow) + } + "new_tab" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::NewTab) + } + "previous_tab" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::PreviousTab) + } + "next_tab" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::NextTab) + } + "last_tab" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::LastTab) + } + "goto_tab" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::GotoTab(usize::parse_param(p)?)) + } + "move_tab" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::MoveTab(isize::parse_param(p)?)) + } + "toggle_tab_overview" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleTabOverview) + } + "prompt_surface_title" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::PromptSurfaceTitle) + } + "prompt_tab_title" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::PromptTabTitle) + } + "set_surface_title" => { + let p = param.ok_or(Error::InvalidFormat)?; + Ok(Action::SetSurfaceTitle(p.to_string())) + } + "set_tab_title" => { + let p = param.ok_or(Error::InvalidFormat)?; + Ok(Action::SetTabTitle(p.to_string())) + } + "new_split" => { + let Some(p) = param else { + return Ok(Action::NewSplit(SplitDirection::DEFAULT)); + }; + Ok(Action::NewSplit(SplitDirection::parse_param(p)?)) + } + "goto_split" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::GotoSplit(SplitFocusDirection::parse_param(p)?)) + } + "goto_window" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::GotoWindow(GotoWindow::parse_param(p)?)) + } + "toggle_split_zoom" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleSplitZoom) + } + "toggle_readonly" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleReadonly) + } + "resize_split" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::ResizeSplit(SplitResizeParameter::parse_param(p)?)) + } + "equalize_splits" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::EqualizeSplits) + } + "reset_window_size" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ResetWindowSize) + } + "inspector" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::Inspector(InspectorMode::parse_param(p)?)) + } + "show_gtk_inspector" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ShowGtkInspector) + } + "show_on_screen_keyboard" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ShowOnScreenKeyboard) + } + "open_config" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::OpenConfig) + } + "reload_config" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ReloadConfig) + } + "close_surface" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::CloseSurface) + } + "close_tab" => { + let Some(p) = param else { + return Ok(Action::CloseTab(CloseTabMode::DEFAULT)); + }; + Ok(Action::CloseTab(CloseTabMode::parse_param(p)?)) + } + "close_window" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::CloseWindow) + } + "close_all_windows" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::CloseAllWindows) + } + "toggle_maximize" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleMaximize) + } + "toggle_fullscreen" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleFullscreen) + } + "toggle_window_decorations" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleWindowDecorations) + } + "toggle_window_float_on_top" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleWindowFloatOnTop) + } + "toggle_secure_input" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleSecureInput) + } + "toggle_mouse_reporting" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleMouseReporting) + } + "toggle_command_palette" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleCommandPalette) + } + "toggle_quick_terminal" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleQuickTerminal) + } + "toggle_visibility" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleVisibility) + } + "toggle_background_opacity" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::ToggleBackgroundOpacity) + } + "check_for_updates" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::CheckForUpdates) + } + "undo" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::Undo) + } + "redo" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::Redo) + } + "end_key_sequence" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::EndKeySequence) + } + "activate_key_table" => { + let p = param.ok_or(Error::InvalidFormat)?; + Ok(Action::ActivateKeyTable(p.to_string())) + } + "activate_key_table_once" => { + let p = param.ok_or(Error::InvalidFormat)?; + Ok(Action::ActivateKeyTableOnce(p.to_string())) + } + "deactivate_key_table" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::DeactivateKeyTable) + } + "deactivate_all_key_tables" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::DeactivateAllKeyTables) + } + "quit" => { + if param.is_some() { + return Err(Error::InvalidFormat); + } + Ok(Action::Quit) + } + "crash" => { + let Some(p) = param else { + return Err(Error::InvalidFormat); + }; + Ok(Action::Crash(CrashThread::parse_param(p)?)) + } + _ => Err(Error::InvalidAction), + } + } + + /// Format only the name and parameter pieces. Used by Display. + fn fmt_into(&self, out: &mut String) { + out.push_str(self.key().name()); + match self { + Action::Ignore + | Action::Unbind + | Action::Reset + | Action::PasteFromClipboard + | Action::PasteFromSelection + | Action::CopyUrlToClipboard + | Action::CopyTitleToClipboard + | Action::ResetFontSize + | Action::SearchSelection + | Action::StartSearch + | Action::EndSearch + | Action::ClearScreen + | Action::SelectAll + | Action::ScrollToTop + | Action::ScrollToBottom + | Action::ScrollToSelection + | Action::ScrollPageUp + | Action::ScrollPageDown + | Action::NewWindow + | Action::NewTab + | Action::PreviousTab + | Action::NextTab + | Action::LastTab + | Action::ToggleTabOverview + | Action::PromptSurfaceTitle + | Action::PromptTabTitle + | Action::ToggleSplitZoom + | Action::ToggleReadonly + | Action::EqualizeSplits + | Action::ResetWindowSize + | Action::ShowGtkInspector + | Action::ShowOnScreenKeyboard + | Action::OpenConfig + | Action::ReloadConfig + | Action::CloseSurface + | Action::CloseWindow + | Action::CloseAllWindows + | Action::ToggleMaximize + | Action::ToggleFullscreen + | Action::ToggleWindowDecorations + | Action::ToggleWindowFloatOnTop + | Action::ToggleSecureInput + | Action::ToggleMouseReporting + | Action::ToggleCommandPalette + | Action::ToggleQuickTerminal + | Action::ToggleVisibility + | Action::ToggleBackgroundOpacity + | Action::CheckForUpdates + | Action::Undo + | Action::Redo + | Action::EndKeySequence + | Action::DeactivateKeyTable + | Action::DeactivateAllKeyTables + | Action::Quit => {} + Action::Csi(v) => { + out.push(':'); + string_escape(v, out); + } + Action::Esc(v) => { + out.push(':'); + string_escape(v, out); + } + Action::Text(v) => { + out.push(':'); + string_escape(v, out); + } + Action::CursorKey(_) => panic!("formatting not supported"), + Action::CopyToClipboard(v) => { + out.push(':'); + out.push_str(v.name()); + } + Action::IncreaseFontSize(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::DecreaseFontSize(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::SetFontSize(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::Search(v) => { + out.push(':'); + string_escape(v, out); + } + Action::NavigateSearch(v) => { + out.push(':'); + out.push_str(v.name()); + } + Action::ScrollToRow(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::ScrollPageFractional(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::ScrollPageLines(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::AdjustSelection(v) => { + out.push(':'); + out.push_str(v.name()); + } + Action::JumpToPrompt(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::WriteScrollbackFile(v) => { + out.push(':'); + v.format_into(out); + } + Action::WriteScreenFile(v) => { + out.push(':'); + v.format_into(out); + } + Action::WriteSelectionFile(v) => { + out.push(':'); + v.format_into(out); + } + Action::GotoTab(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::MoveTab(v) => { + out.push(':'); + out.push_str(&v.to_string()); + } + Action::SetSurfaceTitle(v) => { + out.push(':'); + string_escape(v, out); + } + Action::SetTabTitle(v) => { + out.push(':'); + string_escape(v, out); + } + Action::NewSplit(v) => { + out.push(':'); + out.push_str(v.name()); + } + Action::GotoSplit(v) => { + out.push(':'); + out.push_str(v.name()); + } + Action::GotoWindow(v) => { + out.push(':'); + out.push_str(v.name()); + } + Action::ResizeSplit(v) => { + out.push(':'); + v.format_into(out); + } + Action::Inspector(v) => { + out.push(':'); + out.push_str(v.name()); + } + Action::CloseTab(v) => { + out.push(':'); + out.push_str(v.name()); + } + Action::ActivateKeyTable(v) => { + out.push(':'); + string_escape(v, out); + } + Action::ActivateKeyTableOnce(v) => { + out.push(':'); + string_escape(v, out); + } + Action::Crash(v) => { + out.push(':'); + out.push_str(v.name()); + } + } + } + + pub fn hash_into(&self, h: &mut H) { + use std::hash::Hash; + self.key().hash(h); + match self { + Action::Ignore + | Action::Unbind + | Action::Reset + | Action::PasteFromClipboard + | Action::PasteFromSelection + | Action::CopyUrlToClipboard + | Action::CopyTitleToClipboard + | Action::ResetFontSize + | Action::SearchSelection + | Action::StartSearch + | Action::EndSearch + | Action::ClearScreen + | Action::SelectAll + | Action::ScrollToTop + | Action::ScrollToBottom + | Action::ScrollToSelection + | Action::ScrollPageUp + | Action::ScrollPageDown + | Action::NewWindow + | Action::NewTab + | Action::PreviousTab + | Action::NextTab + | Action::LastTab + | Action::ToggleTabOverview + | Action::PromptSurfaceTitle + | Action::PromptTabTitle + | Action::ToggleSplitZoom + | Action::ToggleReadonly + | Action::EqualizeSplits + | Action::ResetWindowSize + | Action::ShowGtkInspector + | Action::ShowOnScreenKeyboard + | Action::OpenConfig + | Action::ReloadConfig + | Action::CloseSurface + | Action::CloseWindow + | Action::CloseAllWindows + | Action::ToggleMaximize + | Action::ToggleFullscreen + | Action::ToggleWindowDecorations + | Action::ToggleWindowFloatOnTop + | Action::ToggleSecureInput + | Action::ToggleMouseReporting + | Action::ToggleCommandPalette + | Action::ToggleQuickTerminal + | Action::ToggleVisibility + | Action::ToggleBackgroundOpacity + | Action::CheckForUpdates + | Action::Undo + | Action::Redo + | Action::EndKeySequence + | Action::DeactivateKeyTable + | Action::DeactivateAllKeyTables + | Action::Quit => {} + Action::Csi(v) => v.hash(h), + Action::Esc(v) => v.hash(h), + Action::Text(v) => v.hash(h), + Action::CursorKey(v) => v.hash(h), + Action::CopyToClipboard(v) => v.hash(h), + Action::IncreaseFontSize(v) => v.to_bits().hash(h), + Action::DecreaseFontSize(v) => v.to_bits().hash(h), + Action::SetFontSize(v) => v.to_bits().hash(h), + Action::Search(v) => v.hash(h), + Action::NavigateSearch(v) => v.hash(h), + Action::ScrollToRow(v) => v.hash(h), + Action::ScrollPageFractional(v) => v.to_bits().hash(h), + Action::ScrollPageLines(v) => v.hash(h), + Action::AdjustSelection(v) => v.hash(h), + Action::JumpToPrompt(v) => v.hash(h), + Action::WriteScrollbackFile(v) => v.hash(h), + Action::WriteScreenFile(v) => v.hash(h), + Action::WriteSelectionFile(v) => v.hash(h), + Action::GotoTab(v) => v.hash(h), + Action::MoveTab(v) => v.hash(h), + Action::SetSurfaceTitle(v) => v.hash(h), + Action::SetTabTitle(v) => v.hash(h), + Action::NewSplit(v) => v.hash(h), + Action::GotoSplit(v) => v.hash(h), + Action::GotoWindow(v) => v.hash(h), + Action::ResizeSplit(v) => v.hash(h), + Action::Inspector(v) => v.hash(h), + Action::CloseTab(v) => v.hash(h), + Action::ActivateKeyTable(v) => v.hash(h), + Action::ActivateKeyTableOnce(v) => v.hash(h), + Action::Crash(v) => v.hash(h), + } + } + + /// Returns the scope of an action. + pub fn scope(&self) -> Scope { + self.key().scope() + } +} + +impl ActionKey { + /// Returns the scope of an action. + pub fn scope(self) -> Scope { + match self { + ActionKey::Ignore + | ActionKey::Unbind + | ActionKey::OpenConfig + | ActionKey::ReloadConfig + | ActionKey::CloseAllWindows + | ActionKey::Quit + | ActionKey::ToggleQuickTerminal + | ActionKey::ToggleVisibility + | ActionKey::CheckForUpdates + | ActionKey::ShowGtkInspector + | ActionKey::NewWindow + | ActionKey::Undo + | ActionKey::Redo => Scope::App, + ActionKey::Csi + | ActionKey::Esc + | ActionKey::Text + | ActionKey::CursorKey + | ActionKey::Search + | ActionKey::NavigateSearch + | ActionKey::SearchSelection + | ActionKey::StartSearch + | ActionKey::EndSearch + | ActionKey::Reset + | ActionKey::CopyToClipboard + | ActionKey::CopyUrlToClipboard + | ActionKey::CopyTitleToClipboard + | ActionKey::PasteFromClipboard + | ActionKey::PasteFromSelection + | ActionKey::IncreaseFontSize + | ActionKey::DecreaseFontSize + | ActionKey::ResetFontSize + | ActionKey::SetFontSize + | ActionKey::PromptSurfaceTitle + | ActionKey::PromptTabTitle + | ActionKey::SetSurfaceTitle + | ActionKey::SetTabTitle + | ActionKey::ClearScreen + | ActionKey::SelectAll + | ActionKey::ScrollToTop + | ActionKey::ScrollToBottom + | ActionKey::ScrollToSelection + | ActionKey::ScrollToRow + | ActionKey::ScrollPageUp + | ActionKey::ScrollPageDown + | ActionKey::ScrollPageFractional + | ActionKey::ScrollPageLines + | ActionKey::AdjustSelection + | ActionKey::JumpToPrompt + | ActionKey::WriteScrollbackFile + | ActionKey::WriteScreenFile + | ActionKey::WriteSelectionFile + | ActionKey::CloseSurface + | ActionKey::CloseTab + | ActionKey::CloseWindow + | ActionKey::ToggleMaximize + | ActionKey::ToggleFullscreen + | ActionKey::ToggleWindowDecorations + | ActionKey::ToggleWindowFloatOnTop + | ActionKey::ToggleSecureInput + | ActionKey::ToggleMouseReporting + | ActionKey::ToggleCommandPalette + | ActionKey::ToggleBackgroundOpacity + | ActionKey::ShowOnScreenKeyboard + | ActionKey::ResetWindowSize + | ActionKey::ActivateKeyTable + | ActionKey::ActivateKeyTableOnce + | ActionKey::DeactivateKeyTable + | ActionKey::DeactivateAllKeyTables + | ActionKey::EndKeySequence + | ActionKey::Crash + | ActionKey::NewTab + | ActionKey::PreviousTab + | ActionKey::NextTab + | ActionKey::LastTab + | ActionKey::GotoTab + | ActionKey::MoveTab + | ActionKey::ToggleTabOverview + | ActionKey::NewSplit + | ActionKey::GotoSplit + | ActionKey::GotoWindow + | ActionKey::ToggleSplitZoom + | ActionKey::ToggleReadonly + | ActionKey::ResizeSplit + | ActionKey::EqualizeSplits + | ActionKey::Inspector => Scope::Surface, + } + } + + /// Look up an action key by name. + pub fn from_name(s: &str) -> Option { + match s { + "ignore" => Some(ActionKey::Ignore), + "unbind" => Some(ActionKey::Unbind), + "csi" => Some(ActionKey::Csi), + "esc" => Some(ActionKey::Esc), + "text" => Some(ActionKey::Text), + "cursor_key" => Some(ActionKey::CursorKey), + "reset" => Some(ActionKey::Reset), + "copy_to_clipboard" => Some(ActionKey::CopyToClipboard), + "paste_from_clipboard" => Some(ActionKey::PasteFromClipboard), + "paste_from_selection" => Some(ActionKey::PasteFromSelection), + "copy_url_to_clipboard" => Some(ActionKey::CopyUrlToClipboard), + "copy_title_to_clipboard" => Some(ActionKey::CopyTitleToClipboard), + "increase_font_size" => Some(ActionKey::IncreaseFontSize), + "decrease_font_size" => Some(ActionKey::DecreaseFontSize), + "reset_font_size" => Some(ActionKey::ResetFontSize), + "set_font_size" => Some(ActionKey::SetFontSize), + "search" => Some(ActionKey::Search), + "search_selection" => Some(ActionKey::SearchSelection), + "navigate_search" => Some(ActionKey::NavigateSearch), + "start_search" => Some(ActionKey::StartSearch), + "end_search" => Some(ActionKey::EndSearch), + "clear_screen" => Some(ActionKey::ClearScreen), + "select_all" => Some(ActionKey::SelectAll), + "scroll_to_top" => Some(ActionKey::ScrollToTop), + "scroll_to_bottom" => Some(ActionKey::ScrollToBottom), + "scroll_to_selection" => Some(ActionKey::ScrollToSelection), + "scroll_to_row" => Some(ActionKey::ScrollToRow), + "scroll_page_up" => Some(ActionKey::ScrollPageUp), + "scroll_page_down" => Some(ActionKey::ScrollPageDown), + "scroll_page_fractional" => Some(ActionKey::ScrollPageFractional), + "scroll_page_lines" => Some(ActionKey::ScrollPageLines), + "adjust_selection" => Some(ActionKey::AdjustSelection), + "jump_to_prompt" => Some(ActionKey::JumpToPrompt), + "write_scrollback_file" => Some(ActionKey::WriteScrollbackFile), + "write_screen_file" => Some(ActionKey::WriteScreenFile), + "write_selection_file" => Some(ActionKey::WriteSelectionFile), + "new_window" => Some(ActionKey::NewWindow), + "new_tab" => Some(ActionKey::NewTab), + "previous_tab" => Some(ActionKey::PreviousTab), + "next_tab" => Some(ActionKey::NextTab), + "last_tab" => Some(ActionKey::LastTab), + "goto_tab" => Some(ActionKey::GotoTab), + "move_tab" => Some(ActionKey::MoveTab), + "toggle_tab_overview" => Some(ActionKey::ToggleTabOverview), + "prompt_surface_title" => Some(ActionKey::PromptSurfaceTitle), + "prompt_tab_title" => Some(ActionKey::PromptTabTitle), + "set_surface_title" => Some(ActionKey::SetSurfaceTitle), + "set_tab_title" => Some(ActionKey::SetTabTitle), + "new_split" => Some(ActionKey::NewSplit), + "goto_split" => Some(ActionKey::GotoSplit), + "goto_window" => Some(ActionKey::GotoWindow), + "toggle_split_zoom" => Some(ActionKey::ToggleSplitZoom), + "toggle_readonly" => Some(ActionKey::ToggleReadonly), + "resize_split" => Some(ActionKey::ResizeSplit), + "equalize_splits" => Some(ActionKey::EqualizeSplits), + "reset_window_size" => Some(ActionKey::ResetWindowSize), + "inspector" => Some(ActionKey::Inspector), + "show_gtk_inspector" => Some(ActionKey::ShowGtkInspector), + "show_on_screen_keyboard" => Some(ActionKey::ShowOnScreenKeyboard), + "open_config" => Some(ActionKey::OpenConfig), + "reload_config" => Some(ActionKey::ReloadConfig), + "close_surface" => Some(ActionKey::CloseSurface), + "close_tab" => Some(ActionKey::CloseTab), + "close_window" => Some(ActionKey::CloseWindow), + "close_all_windows" => Some(ActionKey::CloseAllWindows), + "toggle_maximize" => Some(ActionKey::ToggleMaximize), + "toggle_fullscreen" => Some(ActionKey::ToggleFullscreen), + "toggle_window_decorations" => Some(ActionKey::ToggleWindowDecorations), + "toggle_window_float_on_top" => Some(ActionKey::ToggleWindowFloatOnTop), + "toggle_secure_input" => Some(ActionKey::ToggleSecureInput), + "toggle_mouse_reporting" => Some(ActionKey::ToggleMouseReporting), + "toggle_command_palette" => Some(ActionKey::ToggleCommandPalette), + "toggle_quick_terminal" => Some(ActionKey::ToggleQuickTerminal), + "toggle_visibility" => Some(ActionKey::ToggleVisibility), + "toggle_background_opacity" => Some(ActionKey::ToggleBackgroundOpacity), + "check_for_updates" => Some(ActionKey::CheckForUpdates), + "undo" => Some(ActionKey::Undo), + "redo" => Some(ActionKey::Redo), + "end_key_sequence" => Some(ActionKey::EndKeySequence), + "activate_key_table" => Some(ActionKey::ActivateKeyTable), + "activate_key_table_once" => Some(ActionKey::ActivateKeyTableOnce), + "deactivate_key_table" => Some(ActionKey::DeactivateKeyTable), + "deactivate_all_key_tables" => Some(ActionKey::DeactivateAllKeyTables), + "quit" => Some(ActionKey::Quit), + "crash" => Some(ActionKey::Crash), + _ => None, + } + } +} diff --git a/crates/ghostty-input/src/binding/mod.rs b/crates/ghostty-input/src/binding/mod.rs new file mode 100644 index 0000000..2a4ff89 --- /dev/null +++ b/crates/ghostty-input/src/binding/mod.rs @@ -0,0 +1,787 @@ +//! A binding maps some input trigger to an action. When the trigger +//! occurs, the action is performed. (ZIG/src/input/Binding.zig) + +pub mod action; +pub mod set; + +use std::collections::hash_map::DefaultHasher; +use std::fmt; +use std::hash::{Hash, Hasher}; + +pub use action::{ + Action, ActionKey, AdjustSelection, CloseTabMode, CopyToClipboard, CrashThread, CursorKey, + GotoWindow, InspectorMode, NavigateSearch, Scope, SplitDirection, SplitFocusDirection, + SplitResizeDirection, SplitResizeParameter, WriteScreen, WriteScreenAction, WriteScreenFormat, +}; +pub use set::Set; + +use crate::key; +use crate::key_mods::{self, Mods}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("invalid format")] + InvalidFormat, + #[error("invalid action")] + InvalidAction, +} + +/// A binding: trigger, action and per-binding flags. +#[derive(Debug, Clone, PartialEq)] +pub struct Binding { + /// The trigger that needs to be performed to execute the action. + pub trigger: Trigger, + + /// The action to take if this binding matches + pub action: Action, + + /// Boolean flags that can be set per binding. + pub flags: Flags, +} + +/// Flags the full binding-scoped flags that can be set per binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Flags { + /// True if this binding should consume the input when the + /// action is triggered. + pub consumed: bool, + + /// True if this binding should be forwarded to all active surfaces + /// in the application. + pub all: bool, + + /// 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. + pub global: bool, + + /// 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. + pub performable: bool, +} + +impl Default for Flags { + fn default() -> Self { + Flags { + consumed: true, + all: false, + global: false, + performable: false, + } + } +} + +impl Flags { + /// Converts this to a C-compatible value (packed bits, `consumed` = bit 0). + pub fn cval(self) -> u8 { + u8::from(self.consumed) + | u8::from(self.all) << 1 + | u8::from(self.global) << 2 + | u8::from(self.performable) << 3 + } +} + +/// Elements yielded by the [`Parser`]. +#[derive(Debug, Clone, PartialEq)] +pub enum Elem { + /// A leader trigger in a sequence. + Leader(Trigger), + + /// The final trigger and action in a sequence. + Binding(Binding), + + /// A chained action `chain=` 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), +} + +/// Full binding parser. The binding parser is implemented as an iterator +/// which yields elements to support multi-key sequences. +#[derive(Debug, Clone)] +pub struct Parser<'a> { + trigger_it: SequenceIterator<'a>, + action: Action, + flags: Flags, + chain: bool, +} + +impl<'a> Parser<'a> { + pub fn new(raw_input: &'a str) -> Result, Error> { + let (flags, start_idx) = parse_flags(raw_input)?; + let 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. + let 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. + let mut offset = 0usize; + let bytes = input.as_bytes(); + while let Some(offset_idx) = input[offset..].find('=') { + // Find: '=+ctrl' or '==action' + let idx = offset + offset_idx; + if idx + 1 < bytes.len() && (bytes[idx + 1] == b'+' || bytes[idx + 1] == b'=') { + offset += offset_idx + 1; + continue; + } + + // Looks like the real equal sign. + break 'eql idx; + } + + return Err(Error::InvalidFormat); + }; + + // Detect chains. Chains must not have flags. + let chain = &input[..eql_idx] == "chain"; + if chain && start_idx > 0 { + return Err(Error::InvalidFormat); + } + + // Sequence iterator goes up to the equal, action is after. We can + // parse the action now. + Ok(Parser { + trigger_it: SequenceIterator { + // 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. + input: if chain { "a" } else { &input[..eql_idx] }, + i: 0, + }, + action: Action::parse(&input[eql_idx + 1..])?, + flags, + chain, + }) + } + + pub fn next_elem(&mut self) -> Result, Error> { + // Get our trigger. If we're out of triggers then we're done. + let Some(trigger) = self.trigger_it.next_trigger()? else { + return Ok(None); + }; + + // 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 || self.flags.all { + return Err(Error::InvalidFormat); + } + return Ok(Some(Elem::Leader(trigger))); + } + + // If we're a chain then return it as-is. + if self.chain { + return Ok(Some(Elem::Chain(self.action.clone()))); + } + + // Out of triggers, yield the final action. + Ok(Some(Elem::Binding(Binding { + trigger, + action: self.action.clone(), + flags: self.flags, + }))) + } + + pub fn reset(&mut self) { + self.trigger_it.i = 0; + } +} + +fn parse_flags(raw_input: &str) -> Result<(Flags, usize), Error> { + let mut flags = Flags::default(); + + let mut start_idx = 0usize; + let mut input = raw_input; + // Find the next prefix + while let Some(idx) = input.find(':') { + let prefix = &input[..idx]; + + // If the prefix is one of our flags then set it. + match prefix { + "all" => { + if flags.all { + return Err(Error::InvalidFormat); + } + flags.all = true; + } + "global" => { + if flags.global { + return Err(Error::InvalidFormat); + } + flags.global = true; + } + "unconsumed" => { + if !flags.consumed { + return Err(Error::InvalidFormat); + } + flags.consumed = false; + } + "performable" => { + if flags.performable { + return Err(Error::InvalidFormat); + } + flags.performable = true; + } + // 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..]; + } + + Ok((flags, start_idx)) +} + +/// 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". +#[derive(Debug, Clone)] +pub struct SequenceIterator<'a> { + /// 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. + pub input: &'a str, + pub i: usize, +} + +impl<'a> SequenceIterator<'a> { + pub fn new(input: &'a str) -> Self { + SequenceIterator { input, i: 0 } + } + + /// Returns the next trigger in the sequence if there is no parsing error. + pub fn next_trigger(&mut self) -> Result, Error> { + if self.done() { + return Ok(None); + } + let rem = &self.input[self.i..]; + let idx = rem.find('>').unwrap_or(rem.len()); + self.i += idx + 1; + Ok(Some(Trigger::parse(&rem[..idx])?)) + } + + /// Returns true if there are no more triggers to parse. + pub fn done(&self) -> bool { + 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseSingleError { + Error(Error), + UnexpectedChain, + UnexpectedSequence, +} + +impl From for ParseSingleError { + fn from(e: Error) -> Self { + ParseSingleError::Error(e) + } +} + +pub fn parse_single(raw_input: &str) -> Result { + let mut p = Parser::new(raw_input)?; + let elem = p.next_elem()?.ok_or(Error::InvalidFormat)?; + match elem { + Elem::Leader(_) => Err(ParseSingleError::UnexpectedSequence), + Elem::Binding(b) => Ok(b), + Elem::Chain(_) => Err(ParseSingleError::UnexpectedChain), + } +} + +/// Returns true if lhs should be sorted before rhs +pub fn less_than(lhs: &Binding, rhs: &Binding) -> bool { + let count = |m: Mods| { + usize::from(m.super_) + usize::from(m.ctrl) + usize::from(m.shift) + usize::from(m.alt) + }; + let lhs_count = count(lhs.trigger.mods); + let rhs_count = count(rhs.trigger.mods); + + 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(); + } + + let k = |t: &Trigger| -> i64 { + match t.key { + TriggerKey::Physical(k) => k as i64, + TriggerKey::Unicode(cp) => i64::from(cp), + // Zig reads the C-union tag's key here (undefined); treat as 0. + TriggerKey::CatchAll => 0, + } + }; + k(&lhs.trigger) < k(&rhs.trigger) +} + +/// The key part of a [`Trigger`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TriggerKey { + /// 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(u32), + + /// A catch-all key that matches any key press that is otherwise + /// unbound. + CatchAll, +} + +/// Trigger is the associated key state that can trigger an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Trigger { + /// The key that has to be pressed for a binding to take action. + pub key: TriggerKey, + + /// The key modifiers that must be active for this to match. + pub mods: Mods, +} + +impl Default for Trigger { + fn default() -> Self { + Trigger { + key: TriggerKey::Physical(key::Key::Unidentified), + mods: Mods::default(), + } + } +} + +/// Backwards compatible key names from Ghostty 1.1.x. Ghostty 1.2+ doesn't +/// support these anymore since we moved to W3C key codes. +pub const BACKWARDS_COMPATIBLE_KEYS: &[(&str, TriggerKey)] = { + use TriggerKey::{Physical as P, Unicode as U}; + use key::Key as K; + &[ + ("zero", U('0' as u32)), + ("one", U('1' as u32)), + ("two", U('2' as u32)), + ("three", U('3' as u32)), + ("four", U('4' as u32)), + ("five", U('5' as u32)), + ("six", U('6' as u32)), + ("seven", U('7' as u32)), + ("eight", U('8' as u32)), + ("nine", U('9' as u32)), + ("plus", U('+' as u32)), + ("apostrophe", U('\'' as u32)), + ("grave_accent", P(K::Backquote)), + ("left_bracket", P(K::BracketLeft)), + ("right_bracket", P(K::BracketRight)), + ("up", P(K::ArrowUp)), + ("down", P(K::ArrowDown)), + ("left", P(K::ArrowLeft)), + ("right", P(K::ArrowRight)), + ("kp_0", P(K::Numpad0)), + ("kp_1", P(K::Numpad1)), + ("kp_2", P(K::Numpad2)), + ("kp_3", P(K::Numpad3)), + ("kp_4", P(K::Numpad4)), + ("kp_5", P(K::Numpad5)), + ("kp_6", P(K::Numpad6)), + ("kp_7", P(K::Numpad7)), + ("kp_8", P(K::Numpad8)), + ("kp_9", P(K::Numpad9)), + ("kp_add", P(K::NumpadAdd)), + ("kp_subtract", P(K::NumpadSubtract)), + ("kp_multiply", P(K::NumpadMultiply)), + ("kp_divide", P(K::NumpadDivide)), + ("kp_decimal", P(K::NumpadDecimal)), + ("kp_enter", P(K::NumpadEnter)), + ("kp_equal", P(K::NumpadEqual)), + ("kp_separator", P(K::NumpadSeparator)), + ("kp_left", P(K::NumpadLeft)), + ("kp_right", P(K::NumpadRight)), + ("kp_up", P(K::NumpadUp)), + ("kp_down", P(K::NumpadDown)), + ("kp_page_up", P(K::NumpadPageUp)), + ("kp_page_down", P(K::NumpadPageDown)), + ("kp_home", P(K::NumpadHome)), + ("kp_end", P(K::NumpadEnd)), + ("kp_insert", P(K::NumpadInsert)), + ("kp_delete", P(K::NumpadDelete)), + ("kp_begin", P(K::NumpadBegin)), + ("left_shift", P(K::ShiftLeft)), + ("right_shift", P(K::ShiftRight)), + ("left_control", P(K::ControlLeft)), + ("right_control", P(K::ControlRight)), + ("left_alt", P(K::AltLeft)), + ("right_alt", P(K::AltRight)), + ("left_super", P(K::MetaLeft)), + ("right_super", P(K::MetaRight)), + // 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", P(K::Digit0)), + ("physical:one", P(K::Digit1)), + ("physical:two", P(K::Digit2)), + ("physical:three", P(K::Digit3)), + ("physical:four", P(K::Digit4)), + ("physical:five", P(K::Digit5)), + ("physical:six", P(K::Digit6)), + ("physical:seven", P(K::Digit7)), + ("physical:eight", P(K::Digit8)), + ("physical:nine", P(K::Digit9)), + ("physical:apostrophe", P(K::Quote)), + ("physical:grave_accent", P(K::Backquote)), + ("physical:left_bracket", P(K::BracketLeft)), + ("physical:right_bracket", P(K::BracketRight)), + ("physical:up", P(K::ArrowUp)), + ("physical:down", P(K::ArrowDown)), + ("physical:left", P(K::ArrowLeft)), + ("physical:right", P(K::ArrowRight)), + ("physical:kp_0", P(K::Numpad0)), + ("physical:kp_1", P(K::Numpad1)), + ("physical:kp_2", P(K::Numpad2)), + ("physical:kp_3", P(K::Numpad3)), + ("physical:kp_4", P(K::Numpad4)), + ("physical:kp_5", P(K::Numpad5)), + ("physical:kp_6", P(K::Numpad6)), + ("physical:kp_7", P(K::Numpad7)), + ("physical:kp_8", P(K::Numpad8)), + ("physical:kp_9", P(K::Numpad9)), + ("physical:kp_add", P(K::NumpadAdd)), + ("physical:kp_subtract", P(K::NumpadSubtract)), + ("physical:kp_multiply", P(K::NumpadMultiply)), + ("physical:kp_divide", P(K::NumpadDivide)), + ("physical:kp_decimal", P(K::NumpadDecimal)), + ("physical:kp_enter", P(K::NumpadEnter)), + ("physical:kp_equal", P(K::NumpadEqual)), + ("physical:kp_separator", P(K::NumpadSeparator)), + ("physical:kp_left", P(K::NumpadLeft)), + ("physical:kp_right", P(K::NumpadRight)), + ("physical:kp_up", P(K::NumpadUp)), + ("physical:kp_down", P(K::NumpadDown)), + ("physical:kp_page_up", P(K::NumpadPageUp)), + ("physical:kp_page_down", P(K::NumpadPageDown)), + ("physical:kp_home", P(K::NumpadHome)), + ("physical:kp_end", P(K::NumpadEnd)), + ("physical:kp_insert", P(K::NumpadInsert)), + ("physical:kp_delete", P(K::NumpadDelete)), + ("physical:kp_begin", P(K::NumpadBegin)), + ("physical:left_shift", P(K::ShiftLeft)), + ("physical:right_shift", P(K::ShiftRight)), + ("physical:left_control", P(K::ControlLeft)), + ("physical:right_control", P(K::ControlRight)), + ("physical:left_alt", P(K::AltLeft)), + ("physical:right_alt", P(K::AltRight)), + ("physical:left_super", P(K::MetaLeft)), + ("physical:right_super", P(K::MetaRight)), + ] +}; + +/// The codepoint we use for comparisons. Case folding can result +/// in more codepoints so we need to use a 3 element array. +/// +/// DEVIATION: Zig uses uucode's full case folding table. Here we use +/// `char::to_lowercase` (single-char results only) plus a few simple-folding +/// special cases; multi-codepoint foldings return the codepoint as-is exactly +/// like Zig does. +fn folded_codepoint(cp: u32) -> [u32; 3] { + // ASCII fast path + if let Some(c) = char::from_u32(cp) + && c.is_ascii_alphabetic() + { + return [u32::from(c.to_ascii_lowercase()), 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. + let Some(c) = char::from_u32(cp) else { + return [cp, 0, 0]; + }; + // Simple-fold differences from lowercase. + match c { + '\u{00B5}' => return [0x03BC, 0, 0], + '\u{017F}' => return ['s' as u32, 0, 0], + '\u{03C2}' => return [0x03C3, 0, 0], + _ => {} + } + let mut it = c.to_lowercase(); + match (it.next(), it.next()) { + (Some(l), None) => [l as u32, 0, 0], + _ => [cp, 0, 0], + } +} + +impl Trigger { + /// 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: &str) -> Result { + if input.is_empty() { + return Err(Error::InvalidFormat); + } + let mut result = Trigger::default(); + let mut rem: &str = input; + 'outer: while !rem.is_empty() { + let idx = rem.find('+').unwrap_or(rem.len()); + let part = &rem[..idx]; + rem = if idx >= rem.len() { + "" + } else { + &rem[idx + 1..] + }; + + // Check if its a modifier + { + let slot: Option<&mut bool> = match part { + "shift" => Some(&mut result.mods.shift), + "ctrl" => Some(&mut result.mods.ctrl), + "alt" => Some(&mut result.mods.alt), + "super" => Some(&mut result.mods.super_), + "caps_lock" => Some(&mut result.mods.caps_lock), + "num_lock" => Some(&mut result.mods.num_lock), + _ => None, + }; + if let Some(slot) = slot { + // Repeat not allowed + if *slot { + return Err(Error::InvalidFormat); + } + *slot = true; + continue 'outer; + } + } + + // Alias modifiers + for (alias, m) in key_mods::ALIAS { + if part == *alias { + // Repeat not allowed + let slot = match m { + key_mods::Mod::Shift => &mut result.mods.shift, + key_mods::Mod::Ctrl => &mut result.mods.ctrl, + key_mods::Mod::Alt => &mut result.mods.alt, + key_mods::Mod::Super => &mut result.mods.super_, + }; + if *slot { + return Err(Error::InvalidFormat); + } + *slot = true; + continue 'outer; + } + } + + // Anything after this point is a key and we only support + // single keys. + if !result.is_key_unset() { + return Err(Error::InvalidFormat); + } + + // If the part is empty it means that it is actually + // a literal `+`, which we treat as a Unicode character. + if part.is_empty() { + result.key = TriggerKey::Unicode('+' as u32); + continue; + } + + // Check if its a key + if part != "unidentified" + && let Some(k) = key::Key::from_name(part) + { + result.key = TriggerKey::Physical(k); + continue; + } + + // If we're still unset and we have exactly one unicode + // character then we can use that as a key. + { + let mut it = part.chars(); + if let (Some(c), None) = (it.next(), it.next()) { + result.key = TriggerKey::Unicode(c as u32); + continue; + } + } + + // Look for a matching w3c name next. + if let Some(w3c_key) = key::Key::from_w3c(part) { + result.key = TriggerKey::Physical(w3c_key); + continue; + } + + // 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 part == "catch_all" { + result.key = TriggerKey::CatchAll; + continue; + } + + // 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 let Some((_, old_key)) = BACKWARDS_COMPATIBLE_KEYS.iter().find(|e| e.0 == part) { + result.key = *old_key; + continue; + } + + // We didn't recognize this value + return Err(Error::InvalidFormat); + } + + Ok(result) + } + + /// Returns true if this trigger has no key set. + pub fn is_key_unset(&self) -> bool { + match self.key { + TriggerKey::Physical(v) => v == key::Key::Unidentified, + TriggerKey::Unicode(_) | TriggerKey::CatchAll => false, + } + } + + /// Returns a hash code that can be used to uniquely identify this trigger. + pub fn hash(&self) -> u64 { + let mut h = DefaultHasher::new(); + self.hash_incremental(&mut h); + h.finish() + } + + /// Hash the trigger into the given hasher (case-folded for unicode keys). + pub fn hash_incremental(&self, h: &mut H) { + std::mem::discriminant(&self.key).hash(h); + match self.key { + TriggerKey::Physical(v) => v.hash(h), + TriggerKey::Unicode(cp) => folded_codepoint(cp).hash(h), + TriggerKey::CatchAll => {} + } + self.mods.binding().hash(h); + } + + /// Returns true if two triggers are equal. + pub fn equal(&self, other: &Trigger) -> bool { + self == other + } + + /// Returns true if two triggers are equal using folded codepoints. + pub fn folded_equal(&self, other: &Trigger) -> bool { + if self.mods != other.mods { + return false; + } + match (self.key, other.key) { + (TriggerKey::Physical(a), TriggerKey::Physical(b)) => a == b, + (TriggerKey::Unicode(a), TriggerKey::Unicode(b)) => { + folded_codepoint(a) == folded_codepoint(b) + } + (TriggerKey::CatchAll, TriggerKey::CatchAll) => true, + _ => false, + } + } + + /// Convert the trigger to a C API compatible trigger. + pub fn cval(&self) -> TriggerC { + match self.key { + TriggerKey::Physical(v) => TriggerC { + tag: TriggerCTag::Physical, + key: v as u32, + mods: self.mods, + }, + TriggerKey::Unicode(v) => TriggerC { + tag: TriggerCTag::Unicode, + key: v, + mods: self.mods, + }, + TriggerKey::CatchAll => TriggerC { + tag: TriggerCTag::CatchAll, + key: 0, + mods: self.mods, + }, + } + } +} + +/// C API tag for triggers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum TriggerCTag { + Physical, + Unicode, + CatchAll, +} + +/// The struct used for triggers in the C API (`Trigger.C`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TriggerC { + pub tag: TriggerCTag, + /// Physical key enum value or unicode codepoint (undefined for catch_all). + pub key: u32, + pub mods: Mods, +} + +impl fmt::Display for Trigger { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Modifiers first + if self.mods.super_ { + f.write_str("super+")?; + } + if self.mods.ctrl { + f.write_str("ctrl+")?; + } + if self.mods.alt { + f.write_str("alt+")?; + } + if self.mods.shift { + f.write_str("shift+")?; + } + + // Key + match self.key { + TriggerKey::Physical(k) => f.write_str(k.name()), + TriggerKey::Unicode(c) => { + // Zig `{u}` writes the codepoint as UTF-8. + match char::from_u32(c) { + Some(ch) => write!(f, "{ch}"), + None => f.write_str("\u{FFFD}"), + } + } + TriggerKey::CatchAll => f.write_str("catch_all"), + } + } +} + +/// Hash-map key wrapper for triggers: equality and hashing use the +/// case-folded comparison (`Trigger.bindingSetEqual`). +#[derive(Debug, Clone, Copy)] +pub struct FoldedTrigger(pub Trigger); + +impl PartialEq for FoldedTrigger { + fn eq(&self, other: &Self) -> bool { + self.0.folded_equal(&other.0) + } +} +impl Eq for FoldedTrigger {} +impl Hash for FoldedTrigger { + fn hash(&self, state: &mut H) { + self.0.hash_incremental(state); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostty-input/src/binding/set.rs b/crates/ghostty-input/src/binding/set.rs new file mode 100644 index 0000000..a725124 --- /dev/null +++ b/crates/ghostty-input/src/binding/set.rs @@ -0,0 +1,681 @@ +//! 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. (`Binding.Set`) + +use std::fmt::Write as _; + +use indexmap::IndexMap; + +use super::{Action, Elem, Error, Flags, FoldedTrigger, Parser, Trigger, TriggerKey}; +use crate::key::KeyEvent; + +/// TODO(integration): stand-in for `config/formatter.zig` `EntryFormatter`. +/// The config crate implements this to write `keybind = ` lines. +pub trait EntryFormatter { + /// Format a single `key = value` entry where `value` is a string. + fn format_entry(&mut self, value: &str); +} + +/// Errors from [`Set::append_chain`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AppendChainError { + #[error("no chain parent")] + NoChainParent, +} + +/// Leaf node of a set is an action to trigger. This is a "leaf" compared +/// to the inner nodes which are "leaders" for sequences. +#[derive(Debug, Clone, PartialEq)] +pub struct Leaf { + pub action: Action, + pub flags: Flags, +} + +impl Leaf { + pub fn hash(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.action.hash_into(&mut h); + self.flags.hash(&mut h); + h.finish() + } + + pub fn generic(&self) -> GenericLeaf<'_> { + GenericLeaf { + flags: self.flags, + actions: std::slice::from_ref(&self.action), + } + } +} + +/// Leaf node of a set that triggers multiple actions in sequence. +#[derive(Debug, Clone, PartialEq)] +pub struct LeafChained { + pub actions: Vec, + pub flags: Flags, +} + +impl LeafChained { + pub fn generic(&self) -> GenericLeaf<'_> { + GenericLeaf { + flags: self.flags, + actions: &self.actions, + } + } +} + +/// A generic leaf node that can be used to unify the handling of +/// leaf and leaf_chained. +#[derive(Debug, Clone, Copy)] +pub struct GenericLeaf<'a> { + pub flags: Flags, + pub actions: &'a [Action], +} + +impl GenericLeaf<'_> { + pub fn actions_slice(&self) -> &[Action] { + self.actions + } +} + +/// The entry type for the forward mapping of trigger to action. +#[derive(Debug, Clone)] +pub enum Value { + /// This key is a leader key in a sequence. You must follow the given + /// set to find the next key in the sequence. + Leader(Box), + + /// 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. + LeafChained(LeafChained), +} + +impl Value { + pub fn as_leaf(&self) -> Option<&Leaf> { + match self { + Value::Leaf(l) => Some(l), + _ => None, + } + } + + pub fn as_leader(&self) -> Option<&Set> { + match self { + Value::Leader(s) => Some(s), + _ => None, + } + } + + pub fn as_leaf_chained(&self) -> Option<&LeafChained> { + match self { + Value::LeafChained(l) => Some(l), + _ => None, + } + } + + /// Implements the formatter for the fmt package. This encodes the + /// action back into the format used by parse. + /// + /// NOTE: the Zig version has no `leaf_chained` prong (it would not + /// compile if used); here only the first action is written. + pub fn format(&self) -> String { + let mut out = String::new(); + match self { + Value::Leader(set) => { + // the leader key was already printed. + for (k, v) in &set.bindings { + let _ = write!(out, ">{}{}", k.0, v.format()); + } + } + Value::Leaf(leaf) => { + // action implements the format + let _ = write!(out, "={}", leaf.action); + } + Value::LeafChained(leaf) => { + if let Some(a) = leaf.actions.first() { + let _ = write!(out, "={a}"); + } + } + } + out + } + + /// 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` is 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 format_entries(&self, buffer: &mut String, formatter: &mut dyn EntryFormatter) { + match self { + Value::Leader(set) => { + // We'll rewind to this position after each sub-entry, + // sharing the prefix between siblings. + let pos = buffer.len(); + for (k, v) in &set.bindings { + buffer.truncate(pos); + let _ = write!(buffer, ">{}", k.0); + v.format_entries(buffer, formatter); + } + buffer.truncate(pos); + } + + Value::Leaf(leaf) => { + // When we get to the leaf, the buffer contains + // the full sequence of keys needed to reach this action. + let pos = buffer.len(); + let _ = write!(buffer, "={}", leaf.action); + formatter.format_entry(buffer); + buffer.truncate(pos); + } + + Value::LeafChained(leaf) => { + let pos = buffer.len(); + for (i, action) in leaf.actions.iter().enumerate() { + if i == 0 { + let _ = write!(buffer, "={action}"); + formatter.format_entry(buffer); + buffer.truncate(pos); + } else { + // DEVIATION: Zig rewinds the shared buffer to 0 and + // overwrites the sequence prefix; we use a scratch + // string so sibling entries keep their prefix. + let line = format!("chain={action}"); + formatter.format_entry(&line); + } + } + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecurseError { + SequenceUnbind, + NoChainParent, + UnexpectedEndOfInput, + InvalidChainAction, +} + +/// A set of bindings with fast lookup. +#[derive(Debug, Default)] +pub struct Set { + /// The set of bindings. + pub bindings: IndexMap, + + /// 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: IndexMap, + + /// The chain parent is the information necessary to attach a chained + /// action to the proper location in our mapping. + /// + /// DEVIATION: Zig stores raw pointers; here it is the path of triggers + /// (through leader sets) from this set to the parent entry. The last + /// element is the parent's own trigger. + chain_parent: Option>, +} + +impl Clone for Set { + /// Deep clone the set. The clone has no chain parent. + fn clone(&self) -> Self { + Set { + bindings: self.bindings.clone(), + reverse: self.reverse.clone(), + chain_parent: None, + } + } +} + +impl Set { + pub fn new() -> Set { + Set::default() + } + + /// Deep clone the set (see [`Clone`]). + pub fn clone_set(&self) -> Set { + self.clone() + } + + /// The trigger path of the current chain parent, if any. + pub fn chain_parent(&self) -> Option<&[Trigger]> { + self.chain_parent.as_deref() + } + + /// The (last) trigger of the chain parent, if any. + pub fn chain_parent_trigger(&self) -> Option { + self.chain_parent.as_ref().and_then(|p| p.last().copied()) + } + + /// The value of the chain parent, if any. + pub fn chain_parent_value(&self) -> Option<&Value> { + let path = self.chain_parent.as_ref()?; + let (last, prefix) = path.split_last()?; + let mut cur = self; + for t in prefix { + cur = cur.bindings.get(&FoldedTrigger(*t))?.as_leader()?; + } + cur.bindings.get(&FoldedTrigger(*last)) + } + + /// 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. + /// + /// Any Error returned is validated before any set modifications are made. + pub fn parse_and_put(&mut self, input: &str) -> Result<(), Error> { + // To make cleanup easier, we ensure that the full sequence is + // valid before making any set modifications. + let mut it = Parser::new(input)?; + while it.next_elem()?.is_some() {} + it.reset(); + + match Set::parse_and_put_recurse(self, &mut it, true) { + // If we have an updated set (a binding was added) then we store + // it for our chain parent. + Ok(Some(path)) => self.chain_parent = Some(path), + + // 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. + // SequenceUnbind reaching the root means we've unbound all the + // way up and this put was a success. + Ok(None) | Err(RecurseError::SequenceUnbind) => self.chain_parent = None, + + // If our parser input was too short then the format + // is invalid because we handle all valid cases. A chain without + // a parent or an invalid chain action are format errors too. + Err( + RecurseError::UnexpectedEndOfInput + | RecurseError::NoChainParent + | RecurseError::InvalidChainAction, + ) => return Err(Error::InvalidFormat), + } + Ok(()) + } + + /// Returns the path (relative to `set`) of the leaf that was + /// ultimately updated if a binding was added. + fn parse_and_put_recurse( + set: &mut Set, + it: &mut Parser<'_>, + root: bool, + ) -> Result>, RecurseError> { + let elem = it + .next_elem() + .ok() + .flatten() + .ok_or(RecurseError::UnexpectedEndOfInput)?; + match elem { + Elem::Leader(t) => { + // If we have a leader, we need to upsert a set for it. + enum Old { + None, + Leaf(Leaf), + Chained(LeafChained), + } + let key = FoldedTrigger(t); + let mut old = Old::None; + if let Some(entry) = set.bindings.get_mut(&key) { + match entry { + // We have an existing leader for this key already + // so recurse into this set. + Value::Leader(s) => { + let r = Set::parse_and_put_recurse(s, it, false); + return match r { + Ok(path) => Ok(path.map(|p| prepend(t, p))), + // 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. + Err(RecurseError::SequenceUnbind) => { + if s.bindings.is_empty() { + set.remove(t); + Err(RecurseError::SequenceUnbind) + } else { + Ok(None) + } + } + Err(e) => Err(e), + }; + } + Value::Leaf(l) => old = Old::Leaf(l.clone()), + Value::LeafChained(l) => old = Old::Chained(l.clone()), + } + // Remove the existing action. Fallthrough as if + // we don't have a leader. + set.remove(t); + } + + // Create our new set for this leader and insert the leader entry + set.bindings.insert(key, Value::Leader(Box::default())); + + // Recurse + let r = { + let Some(Value::Leader(next)) = set.bindings.get_mut(&key) else { + unreachable!("leader was just inserted"); + }; + Set::parse_and_put_recurse(next, it, false) + }; + match r { + Ok(path) => Ok(path.map(|p| prepend(t, p))), + // If our action was to unbind, we restore the old + // action if we have it. + Err(RecurseError::SequenceUnbind) => { + set.remove(t); + match old { + Old::None => {} + Old::Leaf(leaf) => set.put_flags(t, leaf.action, leaf.flags), + Old::Chained(leaf) => { + // Rebuild our chain + let mut actions = leaf.actions.into_iter(); + if let Some(first) = actions.next() { + set.put_flags(t, first, leaf.flags); + for action in actions { + if set.append_chain(action).is_err() { + set.remove(t); + break; + } + } + } + } + } + Ok(None) + } + Err(e) => Err(e), + } + } + + Elem::Binding(b) => match b.action { + Action::Unbind => { + set.remove(b.trigger); + Err(RecurseError::SequenceUnbind) + } + action => { + set.put_flags(b.trigger, action, b.flags); + Ok(set.chain_parent.clone()) + } + }, + + Elem::Chain(action) => { + // Chains can only happen on the root. + debug_assert!(root); + // Unbind is not valid for chains. + if action == Action::Unbind { + return Err(RecurseError::InvalidChainAction); + } + set.append_chain(action) + .map_err(|_| RecurseError::NoChainParent)?; + Ok(set.chain_parent.clone()) + } + } + } + + /// Add a binding to the set. If the binding already exists then + /// this will overwrite it. + pub fn put(&mut self, t: Trigger, action: Action) { + self.put_flags(t, action, Flags::default()); + } + + /// Add a binding to the set with explicit flags. + pub fn put_flags(&mut self, t: Trigger, action: Action, flags: Flags) { + // unbind should never go into the set, it should be handled prior + assert_ne!(action, 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. + let track_reverse = !flags.performable; + + self.chain_parent = None; + + let key = FoldedTrigger(t); + // If we have an existing binding for this trigger, we have to + // update the reverse mapping to remove the old action. A leader or + // chained leaf is simply replaced (chained leaves aren't in the + // reverse mapping). + if track_reverse && matches!(self.bindings.get(&key), Some(Value::Leaf(_))) { + let t_hash = t.hash(); + if let Some(i) = self.reverse.values().position(|v| v.hash() == t_hash) { + self.reverse.swap_remove_index(i); + } + } + + // `insert` keeps the existing key and position (like getOrPut). + self.bindings.insert( + key, + Value::Leaf(Leaf { + action: action.clone(), + flags, + }), + ); + + if track_reverse { + self.reverse.insert(action, t); + } + + let stored = self.bindings.get_key_value(&key).map_or(t, |(k, _)| k.0); + self.chain_parent = Some(vec![stored]); + } + + /// Append a chained action to the prior set action. + /// + /// It is an error if there is no valid prior chain parent. + pub fn append_chain(&mut self, action: Action) -> Result<(), AppendChainError> { + // Unbind is not a valid chain action; callers must check this. + assert_ne!(action, Action::Unbind); + + let path = self + .chain_parent + .clone() + .ok_or(AppendChainError::NoChainParent)?; + let (last, prefix) = path.split_last().ok_or(AppendChainError::NoChainParent)?; + + // Navigate to the set that owns the parent entry. + let mut parent_set: &mut Set = self; + for t in prefix { + match parent_set.bindings.get_mut(&FoldedTrigger(*t)) { + Some(Value::Leader(s)) => parent_set = s, + _ => return Err(AppendChainError::NoChainParent), + } + } + + let key = FoldedTrigger(*last); + let Some((idx, stored_key, value)) = parent_set.bindings.get_full_mut(&key) else { + return Err(AppendChainError::NoChainParent); + }; + let stored_key = stored_key.0; + let _ = idx; + match value { + // Leader can never be a chain parent. Verified through various + // assertions and unit tests. + Value::Leader(_) => unreachable!("leader can never be a chain parent"), + + // If it is already a chained action, we just append the + // action. Easy! + Value::LeafChained(leaf) => leaf.actions.push(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. + Value::Leaf(leaf) => { + let first = leaf.action.clone(); + let flags = leaf.flags; + + // Convert to leaf_chained first, before fixing up reverse + // mapping, so the fixup doesn't find this (now chained) entry. + *value = Value::LeafChained(LeafChained { + actions: vec![first.clone(), action], + 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.fixup_reverse_for_action(&first, stored_key); + } + } + Ok(()) + } + + /// Get a binding for a given trigger. + pub fn get(&self, t: Trigger) -> Option<&Value> { + self.bindings.get(&FoldedTrigger(t)) + } + + /// Get the stored key and value for a given trigger. + pub fn get_entry(&self, t: Trigger) -> Option<(&Trigger, &Value)> { + self.bindings + .get_key_value(&FoldedTrigger(t)) + .map(|(k, v)| (&k.0, v)) + } + + /// Get a trigger for the given action. An action can have multiple + /// triggers so this will return the first one found. + pub fn get_trigger(&self, a: &Action) -> Option { + self.reverse.get(a).copied() + } + + /// 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 get_event(&self, event: &KeyEvent<'_>) -> Option<(&Trigger, &Value)> { + let mut trigger = Trigger { + mods: event.mods.binding(), + key: TriggerKey::Physical(event.key), + }; + if let Some(v) = self.get_entry(trigger) { + return Some(v); + } + + // If our UTF-8 text is exactly one codepoint, we try to match that. + if !event.utf8.is_empty() { + let mut it = event.utf8.chars(); + // No codepoints or multiple codepoints drops to invalid format + if let (Some(cp), None) = (it.next(), it.next()) { + trigger.key = TriggerKey::Unicode(cp as u32); + if let Some(v) = self.get_entry(trigger) { + return Some(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 = TriggerKey::Unicode(event.unshifted_codepoint); + if let Some(v) = self.get_entry(trigger) { + return Some(v); + } + } + + // Fallback to catch_all with modifiers first, then without modifiers. + trigger.key = TriggerKey::CatchAll; + if let Some(v) = self.get_entry(trigger) { + return Some(v); + } + if !trigger.mods.empty() { + trigger.mods = crate::key_mods::Mods::default(); + if let Some(v) = self.get_entry(trigger) { + return Some(v); + } + } + + None + } + + /// Remove a binding for a given trigger. + pub fn remove(&mut self, t: Trigger) { + // 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 = None; + + let Some(entry) = self.bindings.swap_remove(&FoldedTrigger(t)) else { + return; + }; + + match entry { + // For a leader removal, the child set is dropped. Leaders are + // never part of reverse maps so no other accounting is needed. + Value::Leader(_) => {} + + // For an action we need to fix up the reverse mapping. + Value::Leaf(leaf) => self.fixup_reverse_for_action(&leaf.action, t), + + // Chained leaves are never in our reverse mapping so no + // cleanup is required. + Value::LeafChained(_) => {} + } + } + + /// 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. + fn fixup_reverse_for_action(&mut self, action: &Action, old: Trigger) { + let Some(entry) = self.reverse.get_mut(action) else { + 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.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. + let action_hash = action.hash(); + for (k, v) in &self.bindings { + if let Value::Leaf(leaf_search) = v + && leaf_search.action.hash() == action_hash + { + *entry = k.0; + return; + } + } + + // No other trigger points to this action so we remove + // the reverse mapping completely. + self.reverse.swap_remove(action); + } +} + +fn prepend(t: Trigger, mut p: Vec) -> Vec { + p.insert(0, t); + p +} diff --git a/crates/ghostty-input/src/binding/tests.rs b/crates/ghostty-input/src/binding/tests.rs new file mode 100644 index 0000000..79d9b92 --- /dev/null +++ b/crates/ghostty-input/src/binding/tests.rs @@ -0,0 +1,1560 @@ +#![allow(clippy::unwrap_used)] + +use super::set::{AppendChainError, EntryFormatter, Value}; +use super::*; +use crate::key::{Key, KeyEvent}; +use crate::key_mods::Mods; + +fn uni(c: char) -> Trigger { + Trigger { + key: TriggerKey::Unicode(c as u32), + ..Trigger::default() + } +} + +fn phys(k: Key) -> TriggerKey { + TriggerKey::Physical(k) +} + +fn ctrl() -> Mods { + Mods { + ctrl: true, + ..Mods::default() + } +} + +fn shift() -> Mods { + Mods { + shift: true, + ..Mods::default() + } +} + +fn bind(trigger: Trigger, action: Action) -> Binding { + Binding { + trigger, + action, + flags: Flags::default(), + } +} + +fn bind_flags(trigger: Trigger, action: Action, flags: Flags) -> Binding { + Binding { + trigger, + action, + flags, + } +} + +fn trig(mods: Mods, key: TriggerKey) -> Trigger { + Trigger { key, mods } +} + +fn ps(s: &str) -> Binding { + parse_single(s).unwrap() +} + +fn ps_err(s: &str) -> ParseSingleError { + parse_single(s).unwrap_err() +} + +const INVALID: ParseSingleError = ParseSingleError::Error(Error::InvalidFormat); + +#[test] +fn flags_cval() { + assert_eq!(Flags::default().cval(), 0b0001); + assert_eq!( + Flags { + consumed: false, + ..Flags::default() + } + .cval(), + 0b0000 + ); + assert_eq!( + Flags { + all: true, + ..Flags::default() + } + .cval(), + 0b0011 + ); + assert_eq!( + Flags { + global: true, + ..Flags::default() + } + .cval(), + 0b0101 + ); + assert_eq!( + Flags { + performable: true, + ..Flags::default() + } + .cval(), + 0b1001 + ); + assert_eq!( + Flags { + consumed: true, + all: true, + global: true, + performable: true + } + .cval(), + 0b1111 + ); +} + +#[test] +fn split_focus_direction_parse() { + use SplitFocusDirection as D; + assert_eq!(D::parse("previous"), Ok(D::Previous)); + assert_eq!(D::parse("next"), Ok(D::Next)); + assert_eq!(D::parse("up"), Ok(D::Up)); + assert_eq!(D::parse("left"), Ok(D::Left)); + assert_eq!(D::parse("down"), Ok(D::Down)); + assert_eq!(D::parse("right"), Ok(D::Right)); + assert_eq!(D::parse("top"), Ok(D::Up)); + assert_eq!(D::parse("bottom"), Ok(D::Down)); + assert_eq!(D::parse(""), Err(Error::InvalidFormat)); + assert_eq!(D::parse("green"), Err(Error::InvalidFormat)); +} + +#[test] +fn parse_triggers() { + // single character + assert_eq!(ps("a=ignore"), bind(uni('a'), Action::Ignore)); + + // single modifier + assert_eq!( + ps("shift+a=ignore"), + bind( + trig(shift(), TriggerKey::Unicode('a' as u32)), + Action::Ignore + ) + ); + assert_eq!( + ps("ctrl+a=ignore"), + bind( + trig(ctrl(), TriggerKey::Unicode('a' as u32)), + Action::Ignore + ) + ); + + // multiple modifier + assert_eq!( + ps("shift+ctrl+a=ignore"), + bind( + trig( + Mods { + shift: true, + ctrl: true, + ..Mods::default() + }, + TriggerKey::Unicode('a' as u32) + ), + Action::Ignore + ) + ); + + // key can come before modifier + assert_eq!( + ps("a+shift=ignore"), + bind( + trig(shift(), TriggerKey::Unicode('a' as u32)), + Action::Ignore + ) + ); + + // physical keys + assert_eq!( + ps("shift+key_a=ignore"), + bind(trig(shift(), phys(Key::KeyA)), Action::Ignore) + ); + + // unicode keys + assert_eq!( + ps("shift+ö=ignore"), + bind( + trig(shift(), TriggerKey::Unicode('ö' as u32)), + Action::Ignore + ) + ); + + // unconsumed keys + let unconsumed = Flags { + consumed: false, + ..Flags::default() + }; + assert_eq!( + ps("unconsumed:shift+a=ignore"), + bind_flags( + trig(shift(), TriggerKey::Unicode('a' as u32)), + Action::Ignore, + unconsumed + ) + ); + + // unconsumed physical keys + assert_eq!( + ps("unconsumed:key_a+shift=ignore"), + bind_flags(trig(shift(), phys(Key::KeyA)), Action::Ignore, unconsumed) + ); + + // performable keys + assert_eq!( + ps("performable:shift+a=ignore"), + bind_flags( + trig(shift(), TriggerKey::Unicode('a' as u32)), + Action::Ignore, + Flags { + performable: true, + ..Flags::default() + } + ) + ); + + // invalid key + assert_eq!(ps_err("foo=ignore"), INVALID); + + // repeated control + assert_eq!(ps_err("shift+shift+a=ignore"), INVALID); + + // multiple character + assert_eq!(ps_err("a+b=ignore"), INVALID); +} + +#[test] +fn parse_w3c_key_names() { + // Exact match + assert_eq!( + ps("KeyA=ignore"), + bind(trig(Mods::default(), phys(Key::KeyA)), Action::Ignore) + ); + + // Case-sensitive + assert_eq!(ps_err("Keya=ignore"), INVALID); +} + +#[test] +fn parse_catch_all() { + assert_eq!( + ps("catch_all=ignore"), + bind(trig(Mods::default(), TriggerKey::CatchAll), Action::Ignore) + ); + assert_eq!( + ps("ctrl+catch_all=ignore"), + bind(trig(ctrl(), TriggerKey::CatchAll), Action::Ignore) + ); +} + +#[test] +fn parse_plus_sign() { + assert_eq!(ps("+=ignore"), bind(uni('+'), Action::Ignore)); + + // Modifier + assert_eq!( + ps("ctrl++=ignore"), + bind( + trig(ctrl(), TriggerKey::Unicode('+' as u32)), + Action::Ignore + ) + ); + + assert_eq!(ps_err("++=ignore"), INVALID); +} + +#[test] +fn parse_equals_sign() { + assert_eq!(ps("==ignore"), bind(uni('='), Action::Ignore)); + + // Modifier + assert_eq!( + ps("ctrl+==ignore"), + bind( + trig(ctrl(), TriggerKey::Unicode('=' as u32)), + Action::Ignore + ) + ); + + assert_eq!(ps_err("=ignore"), INVALID); +} + +#[test] +fn parse_text_action_equals_sign() { + let b = ps("==text:="); + assert_eq!(b.trigger, uni('=')); + assert_eq!(b.action, Action::Text("=".into())); + + let b = ps("==text:=hello"); + assert_eq!(b.trigger, uni('=')); + assert_eq!(b.action, Action::Text("=hello".into())); + + let b = ps("ctrl+==text:=hello"); + assert_eq!(b.trigger, trig(ctrl(), TriggerKey::Unicode('=' as u32))); + assert_eq!(b.action, Action::Text("=hello".into())); + + let b = ps("=+ctrl=text:=hello"); + assert_eq!(b.trigger, trig(ctrl(), TriggerKey::Unicode('=' as u32))); + assert_eq!(b.action, Action::Text("=hello".into())); +} + +// 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. +#[test] +fn parse_backwards_compatibility_with_le_1_1_x() { + // simple, for sanity + assert_eq!(ps("zero=ignore"), bind(uni('0'), Action::Ignore)); + assert_eq!( + ps("physical:zero=ignore"), + bind(trig(Mods::default(), phys(Key::Digit0)), Action::Ignore) + ); + + // duplicates + assert_eq!(ps_err("zero+one=ignore"), INVALID); + + // test our full map + for (k, v) in BACKWARDS_COMPATIBLE_KEYS { + assert_eq!( + ps(&format!("{k}=ignore")), + bind(trig(Mods::default(), *v), Action::Ignore), + "{k}" + ); + } +} + +#[test] +fn parse_global_triggers() { + let g = Flags { + global: true, + ..Flags::default() + }; + assert_eq!( + ps("global:shift+a=ignore"), + bind_flags( + trig(shift(), TriggerKey::Unicode('a' as u32)), + Action::Ignore, + g + ) + ); + assert_eq!( + ps("global:key_a+shift=ignore"), + bind_flags(trig(shift(), phys(Key::KeyA)), Action::Ignore, g) + ); + assert_eq!( + ps("unconsumed:global:a+shift=ignore"), + bind_flags( + trig(shift(), TriggerKey::Unicode('a' as u32)), + Action::Ignore, + Flags { + global: true, + consumed: false, + ..Flags::default() + } + ) + ); + + // global sequences not allowed + let mut p = Parser::new("global:a>b=ignore").unwrap(); + assert_eq!(p.next_elem(), Err(Error::InvalidFormat)); +} + +#[test] +fn parse_all_triggers() { + let a = Flags { + all: true, + ..Flags::default() + }; + assert_eq!( + ps("all:shift+a=ignore"), + bind_flags( + trig(shift(), TriggerKey::Unicode('a' as u32)), + Action::Ignore, + a + ) + ); + assert_eq!( + ps("all:key_a+shift=ignore"), + bind_flags(trig(shift(), phys(Key::KeyA)), Action::Ignore, a) + ); + assert_eq!( + ps("unconsumed:all:a+shift=ignore"), + bind_flags( + trig(shift(), TriggerKey::Unicode('a' as u32)), + Action::Ignore, + Flags { + all: true, + consumed: false, + ..Flags::default() + } + ) + ); + + // all sequences not allowed + let mut p = Parser::new("all:a>b=ignore").unwrap(); + assert_eq!(p.next_elem(), Err(Error::InvalidFormat)); +} + +#[test] +fn trigger_equal() { + let t = |key, mods| Trigger { key, mods }; + let up = phys(Key::ArrowUp); + let down = phys(Key::ArrowDown); + + // Equal physical keys + assert!(t(up, ctrl()).equal(&t(up, ctrl()))); + // Different physical keys + assert!(!t(up, ctrl()).equal(&t(down, ctrl()))); + // Different mods + assert!(!t(up, ctrl()).equal(&t(up, shift()))); + // Equal unicode keys + let ua = TriggerKey::Unicode('a' as u32); + let ub = TriggerKey::Unicode('b' as u32); + assert!(t(ua, Mods::default()).equal(&t(ua, Mods::default()))); + // Different unicode keys + assert!(!t(ua, Mods::default()).equal(&t(ub, Mods::default()))); + // Different key types + assert!(!t(ua, Mods::default()).equal(&t(phys(Key::KeyA), Mods::default()))); + // catch_all + let ca = TriggerKey::CatchAll; + assert!(t(ca, Mods::default()).equal(&t(ca, Mods::default()))); + // catch_all with different mods + let alt = Mods { + alt: true, + ..Mods::default() + }; + assert!(!t(ca, Mods::default()).equal(&t(ca, alt))); +} + +#[test] +fn parse_modifier_aliases() { + let sup = Mods { + super_: true, + ..Mods::default() + }; + let alt = Mods { + alt: true, + ..Mods::default() + }; + let a = TriggerKey::Unicode('a' as u32); + assert_eq!(ps("cmd+a=ignore"), bind(trig(sup, a), Action::Ignore)); + assert_eq!(ps("command+a=ignore"), bind(trig(sup, a), Action::Ignore)); + assert_eq!(ps("opt+a=ignore"), bind(trig(alt, a), Action::Ignore)); + assert_eq!(ps("option+a=ignore"), bind(trig(alt, a), Action::Ignore)); + assert_eq!( + ps("control+a=ignore"), + bind(trig(ctrl(), a), Action::Ignore) + ); +} + +#[test] +fn parse_action_invalid() { + // invalid action + assert_eq!( + ps_err("a=nopenopenope"), + ParseSingleError::Error(Error::InvalidAction) + ); +} + +#[test] +fn parse_action_no_parameters() { + assert_eq!(ps("a=ignore"), bind(uni('a'), Action::Ignore)); + assert_eq!(ps_err("a=ignore:A"), INVALID); +} + +#[test] +fn parse_action_with_string() { + assert_eq!(ps("a=csi:A").action, Action::Csi("A".into())); + assert_eq!(ps("a=esc:A").action, Action::Esc("A".into())); + assert_eq!( + ps("a=set_surface_title:surface").action, + Action::SetSurfaceTitle("surface".into()) + ); + assert_eq!( + ps("a=set_tab_title:tab").action, + Action::SetTabTitle("tab".into()) + ); +} + +#[test] +fn parse_action_with_enum() { + assert_eq!( + ps("a=new_split:right").action, + Action::NewSplit(SplitDirection::Right) + ); +} + +#[test] +fn parse_action_with_enum_with_default() { + assert_eq!( + ps("a=new_split").action, + Action::NewSplit(SplitDirection::Auto) + ); +} + +#[test] +fn parse_action_with_int() { + assert_eq!(ps("a=jump_to_prompt:-1").action, Action::JumpToPrompt(-1)); + assert_eq!(ps("a=jump_to_prompt:10").action, Action::JumpToPrompt(10)); +} + +#[test] +fn parse_action_with_float() { + assert_eq!( + ps("a=scroll_page_fractional:-0.5").action, + Action::ScrollPageFractional(-0.5) + ); + assert_eq!( + ps("a=scroll_page_fractional:+0.5").action, + Action::ScrollPageFractional(0.5) + ); +} + +#[test] +fn parse_action_with_a_tuple() { + assert_eq!( + ps("a=resize_split:up,10").action, + Action::ResizeSplit(SplitResizeParameter(SplitResizeDirection::Up, 10)) + ); + + // missing parameter + assert_eq!(ps_err("a=resize_split:up"), INVALID); + // too many + assert_eq!(ps_err("a=resize_split:up,10,12"), INVALID); + // invalid type + assert_eq!(ps_err("a=resize_split:up,four"), INVALID); +} + +#[test] +fn parse_chain() { + // Valid + { + let mut p = Parser::new("chain=new_tab").unwrap(); + assert_eq!(p.next_elem(), Ok(Some(Elem::Chain(Action::NewTab)))); + assert_eq!(p.next_elem(), Ok(None)); + } + + // Chain can't have flags + assert_eq!( + Parser::new("global:chain=ignore").unwrap_err(), + Error::InvalidFormat + ); + + // Chain can't be part of a sequence + { + let mut p = Parser::new("a>chain=ignore").unwrap(); + let _ = p.next_elem(); + assert_eq!(p.next_elem(), Err(Error::InvalidFormat)); + } +} + +#[test] +fn sequence_iterator() { + // single character + { + let mut it = SequenceIterator::new("a"); + assert_eq!(it.next_trigger(), Ok(Some(uni('a')))); + assert_eq!(it.next_trigger(), Ok(None)); + } + + // multi character + { + let mut it = SequenceIterator::new("a>b"); + assert_eq!(it.next_trigger(), Ok(Some(uni('a')))); + assert_eq!(it.next_trigger(), Ok(Some(uni('b')))); + assert_eq!(it.next_trigger(), Ok(None)); + } + + // empty + { + let mut it = SequenceIterator::new(""); + assert_eq!(it.next_trigger(), Err(Error::InvalidFormat)); + } + + // empty starting sequence + { + let mut it = SequenceIterator::new(">a"); + assert_eq!(it.next_trigger(), Err(Error::InvalidFormat)); + } + + // empty ending sequence + { + let mut it = SequenceIterator::new("a>"); + assert_eq!(it.next_trigger(), Ok(Some(uni('a')))); + assert_eq!(it.next_trigger(), Err(Error::InvalidFormat)); + } +} + +#[test] +fn parse_sequences() { + // single character + { + let mut p = Parser::new("ctrl+a=ignore").unwrap(); + assert_eq!( + p.next_elem(), + Ok(Some(Elem::Binding(bind( + trig(ctrl(), TriggerKey::Unicode('a' as u32)), + Action::Ignore + )))) + ); + assert_eq!(p.next_elem(), Ok(None)); + } + + // sequence + { + let mut p = Parser::new("a>b=ignore").unwrap(); + assert_eq!(p.next_elem(), Ok(Some(Elem::Leader(uni('a'))))); + assert_eq!( + p.next_elem(), + Ok(Some(Elem::Binding(bind(uni('b'), Action::Ignore)))) + ); + assert_eq!(p.next_elem(), Ok(None)); + } +} + +fn leaf(s: &Set, t: Trigger) -> &set::Leaf { + s.get(t).unwrap().as_leaf().unwrap() +} + +#[test] +fn set_parse_and_put_typical_binding() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + + // Creates forward mapping + let l = leaf(&s, uni('a')); + assert_eq!(l.action, Action::NewWindow); + assert_eq!(l.flags, Flags::default()); + + // Creates reverse mapping + let trigger = s.get_trigger(&Action::NewWindow).unwrap(); + assert_eq!(trigger.key, TriggerKey::Unicode('a' as u32)); + + // Sets up the chain parent properly + assert!(s.chain_parent().is_some()); + assert_eq!(s.chain_parent_trigger().unwrap().to_string(), "a"); +} + +#[test] +fn set_parse_and_put_unconsumed_binding() { + let mut s = Set::new(); + s.parse_and_put("unconsumed:a=new_window").unwrap(); + + let l = leaf(&s, uni('a')); + assert_eq!(l.action, Action::NewWindow); + assert_eq!( + l.flags, + Flags { + consumed: false, + ..Flags::default() + } + ); + + let trigger = s.get_trigger(&Action::NewWindow).unwrap(); + assert_eq!(trigger.key, TriggerKey::Unicode('a' as u32)); + + assert!(s.chain_parent().is_some()); + assert_eq!(s.chain_parent_trigger().unwrap().to_string(), "a"); +} + +#[test] +fn set_parse_and_put_removed_binding() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + s.parse_and_put("a=unbind").unwrap(); + + assert!(s.get(uni('a')).is_none()); + assert!(s.get_trigger(&Action::NewWindow).is_none()); + assert!(s.chain_parent().is_none()); +} + +#[test] +fn set_put_sets_chain_parent() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + + assert!(s.chain_parent().is_some()); + assert_eq!(s.chain_parent_trigger().unwrap().to_string(), "a"); + assert!(matches!(s.chain_parent_value(), Some(Value::Leaf(_)))); +} + +#[test] +fn set_put_flags_sets_chain_parent() { + let mut s = Set::new(); + s.put_flags( + uni('a'), + Action::NewWindow, + Flags { + consumed: false, + ..Flags::default() + }, + ); + + assert_eq!(s.chain_parent_trigger().unwrap().to_string(), "a"); + let v = s.chain_parent_value().unwrap().as_leaf().unwrap(); + assert!(!v.flags.consumed); +} + +#[test] +fn set_sequence_sets_chain_parent_to_final_leaf() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + + assert_eq!(s.chain_parent_trigger().unwrap().to_string(), "b"); + let v = s.chain_parent_value().unwrap().as_leaf().unwrap(); + assert_eq!(v.action, Action::NewWindow); +} + +#[test] +fn set_multiple_leaves_under_leader_updates_chain_parent() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + assert_eq!(s.chain_parent_trigger().unwrap().to_string(), "b"); + + s.parse_and_put("a>c=new_tab").unwrap(); + assert_eq!(s.chain_parent_trigger().unwrap().to_string(), "c"); + assert_eq!( + s.chain_parent_value().unwrap().as_leaf().unwrap().action, + Action::NewTab + ); +} + +#[test] +fn set_sequence_unbind_clears_chain_parent() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + assert!(s.chain_parent().is_some()); + + s.parse_and_put("a>b=unbind").unwrap(); + assert!(s.chain_parent().is_none()); +} + +#[test] +fn set_sequence_unbind_with_remaining_leaves_clears_chain_parent() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + s.parse_and_put("a>c=new_tab").unwrap(); + s.parse_and_put("a>b=unbind").unwrap(); + + // After unbind, chain_parent should be cleared even though 'c' remains + assert!(s.chain_parent().is_none()); + + // But 'c' should still exist + let inner = s.get(uni('a')).unwrap().as_leader().unwrap(); + assert!(inner.get(uni('c')).is_some()); +} + +#[test] +fn set_direct_remove_clears_chain_parent() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + assert!(s.chain_parent().is_some()); + + s.remove(uni('a')); + assert!(s.chain_parent().is_none()); +} + +#[test] +fn set_invalid_format_preserves_chain_parent() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + let before = s.chain_parent().map(<[Trigger]>::to_vec); + + // Try an invalid parse - should fail + assert_eq!( + s.parse_and_put("a=invalid_action_xyz"), + Err(Error::InvalidAction) + ); + + // chain_parent should be unchanged + assert!(s.chain_parent().is_some()); + assert_eq!(s.chain_parent().map(<[Trigger]>::to_vec), before); +} + +#[test] +fn set_clone_produces_null_chain_parent() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + assert!(s.chain_parent().is_some()); + + let cloned = s.clone_set(); + assert!(cloned.chain_parent().is_none()); + assert!(cloned.get(uni('a')).is_some()); +} + +#[test] +fn set_clone_with_leaf_chained() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + s.parse_and_put("chain=new_tab").unwrap(); + + let entry = s.get(uni('a')).unwrap(); + assert_eq!(entry.as_leaf_chained().unwrap().actions.len(), 2); + + let cloned = s.clone_set(); + let ce = cloned.get(uni('a')).unwrap().as_leaf_chained().unwrap(); + assert_eq!(ce.actions.len(), 2); + assert_eq!(ce.actions[0], Action::NewWindow); + assert_eq!(ce.actions[1], Action::NewTab); +} + +#[test] +fn set_clone_with_leaf_chained_containing_allocated_data() { + let mut s = Set::new(); + s.parse_and_put("a=text:hello").unwrap(); + s.parse_and_put("chain=text:world").unwrap(); + + assert!(s.get(uni('a')).unwrap().as_leaf_chained().is_some()); + + let cloned = s.clone_set(); + let ce = cloned.get(uni('a')).unwrap().as_leaf_chained().unwrap(); + assert_eq!(ce.actions[0], Action::Text("hello".into())); + assert_eq!(ce.actions[1], Action::Text("world".into())); + + // Truly cloned, not shared. + let (Action::Text(orig), Action::Text(copy)) = ( + &s.get(uni('a')).unwrap().as_leaf_chained().unwrap().actions[0], + &ce.actions[0], + ) else { + panic!("expected text"); + }; + assert_ne!(orig.as_ptr(), copy.as_ptr()); +} + +#[test] +fn set_parse_and_put_sequence() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + let inner = s.get(uni('a')).unwrap().as_leader().unwrap(); + let l = leaf(inner, uni('b')); + assert_eq!(l.action, Action::NewWindow); + assert_eq!(l.flags, Flags::default()); +} + +#[test] +fn set_parse_and_put_sequence_with_two_actions() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + s.parse_and_put("a>c=new_tab").unwrap(); + let inner = s.get(uni('a')).unwrap().as_leader().unwrap(); + let b = leaf(inner, uni('b')); + assert_eq!(b.action, Action::NewWindow); + assert_eq!(b.flags, Flags::default()); + let c = leaf(inner, uni('c')); + assert_eq!(c.action, Action::NewTab); + assert_eq!(c.flags, Flags::default()); +} + +#[test] +fn set_parse_and_put_overwrite_sequence() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_tab").unwrap(); + s.parse_and_put("a>b=new_window").unwrap(); + let inner = s.get(uni('a')).unwrap().as_leader().unwrap(); + let b = leaf(inner, uni('b')); + assert_eq!(b.action, Action::NewWindow); + assert_eq!(b.flags, Flags::default()); +} + +#[test] +fn set_parse_and_put_overwrite_leader() { + let mut s = Set::new(); + s.parse_and_put("a=new_tab").unwrap(); + s.parse_and_put("a>b=new_window").unwrap(); + let inner = s.get(uni('a')).unwrap().as_leader().unwrap(); + let b = leaf(inner, uni('b')); + assert_eq!(b.action, Action::NewWindow); + assert_eq!(b.flags, Flags::default()); +} + +#[test] +fn set_parse_and_put_unbind_sequence_unbinds_leader() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + s.parse_and_put("a>b=unbind").unwrap(); + assert!(s.get(uni('a')).is_none()); +} + +#[test] +fn set_parse_and_put_unbind_sequence_unbinds_leader_if_not_set() { + let mut s = Set::new(); + s.parse_and_put("a>b=unbind").unwrap(); + assert!(s.get(uni('a')).is_none()); +} + +#[test] +fn set_parse_and_put_unbind_sequence_restores_previous_leaf() { + // Extra: a failed leader insert must restore the old leaf binding. + let mut s = Set::new(); + s.parse_and_put("a=new_tab").unwrap(); + s.parse_and_put("a>b=unbind").unwrap(); + assert_eq!(leaf(&s, uni('a')).action, Action::NewTab); + assert!(s.chain_parent().is_none()); +} + +#[test] +fn set_parse_and_put_sequence_preserves_reverse_mapping() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + s.parse_and_put("ctrl+a>b=new_window").unwrap(); + + let trigger = s.get_trigger(&Action::NewWindow).unwrap(); + assert_eq!(trigger.key, TriggerKey::Unicode('a' as u32)); +} + +#[test] +fn set_put_overwrites_sequence() { + let mut s = Set::new(); + s.parse_and_put("ctrl+a>b=new_window").unwrap(); + s.put( + trig(ctrl(), TriggerKey::Unicode('a' as u32)), + Action::NewWindow, + ); + + let trigger = s.get_trigger(&Action::NewWindow).unwrap(); + assert_eq!(trigger.key, TriggerKey::Unicode('a' as u32)); +} + +#[test] +fn set_maintains_reverse_mapping() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('a' as u32) + ); + + // should be most recent + s.put(uni('b'), Action::NewWindow); + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('b' as u32) + ); + + // removal should replace + s.remove(uni('b')); + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('a' as u32) + ); +} + +#[test] +fn set_performable_is_not_part_of_reverse_mappings() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('a' as u32) + ); + + // trigger should be non-performable + s.put_flags( + uni('b'), + Action::NewWindow, + Flags { + performable: true, + ..Flags::default() + }, + ); + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('a' as u32) + ); + + // removal of performable should do nothing + s.remove(uni('b')); + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('a' as u32) + ); +} + +#[test] +fn set_overriding_a_mapping_updates_reverse() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('a' as u32) + ); + + // should be most recent + s.put(uni('a'), Action::NewTab); + assert!(s.get_trigger(&Action::NewWindow).is_none()); +} + +#[test] +fn set_consumed_state() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + assert!(leaf(&s, uni('a')).flags.consumed); + + s.put_flags( + uni('a'), + Action::NewWindow, + Flags { + consumed: false, + ..Flags::default() + }, + ); + assert!(!leaf(&s, uni('a')).flags.consumed); + + s.put(uni('a'), Action::NewWindow); + assert!(leaf(&s, uni('a')).flags.consumed); +} + +#[test] +fn set_parse_and_put_chain() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + s.parse_and_put("chain=new_tab").unwrap(); + + // Creates forward mapping as leaf_chained + let chained = s.get(uni('a')).unwrap().as_leaf_chained().unwrap(); + assert_eq!(chained.actions.len(), 2); + assert_eq!(chained.actions[0], Action::NewWindow); + assert_eq!(chained.actions[1], Action::NewTab); + + // Does not create reverse mapping, because reverse mappings are only for + // non-chained actions. + assert!(s.get_trigger(&Action::NewWindow).is_none()); +} + +#[test] +fn set_parse_and_put_chain_without_parent_is_error() { + let mut s = Set::new(); + assert_eq!(s.parse_and_put("chain=new_tab"), Err(Error::InvalidFormat)); +} + +#[test] +fn set_parse_and_put_chain_multiple_times() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + s.parse_and_put("chain=new_tab").unwrap(); + s.parse_and_put("chain=close_surface").unwrap(); + + let chained = s.get(uni('a')).unwrap().as_leaf_chained().unwrap(); + assert_eq!(chained.actions.len(), 3); + assert_eq!(chained.actions[0], Action::NewWindow); + assert_eq!(chained.actions[1], Action::NewTab); + assert_eq!(chained.actions[2], Action::CloseSurface); +} + +#[test] +fn set_parse_and_put_chain_preserves_flags() { + let mut s = Set::new(); + s.parse_and_put("unconsumed:a=new_window").unwrap(); + s.parse_and_put("chain=new_tab").unwrap(); + + let chained = s.get(uni('a')).unwrap().as_leaf_chained().unwrap(); + assert!(!chained.flags.consumed); + assert_eq!(chained.actions.len(), 2); +} + +#[test] +fn set_parse_and_put_chain_after_unbind_is_error() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + s.parse_and_put("a=unbind").unwrap(); + + // Chain after unbind should fail because chain_parent is cleared + assert_eq!(s.parse_and_put("chain=new_tab"), Err(Error::InvalidFormat)); +} + +#[test] +fn set_parse_and_put_chain_on_sequence() { + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + s.parse_and_put("chain=new_tab").unwrap(); + + let inner = s.get(uni('a')).unwrap().as_leader().unwrap(); + let chained = inner.get(uni('b')).unwrap().as_leaf_chained().unwrap(); + assert_eq!(chained.actions.len(), 2); + assert_eq!(chained.actions[0], Action::NewWindow); + assert_eq!(chained.actions[1], Action::NewTab); +} + +#[test] +fn set_parse_and_put_chain_with_unbind_is_error() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + + // chain=unbind is not valid + assert_eq!(s.parse_and_put("chain=unbind"), Err(Error::InvalidFormat)); + + // Original binding should still exist + assert_eq!(leaf(&s, uni('a')).action, Action::NewWindow); +} + +fn ev(key: Key, mods: Mods, utf8: &str, unshifted: u32) -> KeyEvent<'_> { + KeyEvent { + key, + mods, + utf8, + unshifted_codepoint: unshifted, + ..KeyEvent::default() + } +} + +fn ev_action(s: &Set, e: &KeyEvent<'_>) -> Option { + s.get_event(e) + .map(|(_, v)| v.as_leaf().unwrap().action.clone()) +} + +#[test] +fn set_get_event_physical() { + let mut s = Set::new(); + s.parse_and_put("ctrl+quote=new_window").unwrap(); + + // Physical matches on physical + assert_eq!( + ev_action(&s, &ev(Key::Quote, ctrl(), "", 0)), + Some(Action::NewWindow) + ); + + // Physical does not match on UTF8/codepoint + assert_eq!( + ev_action(&s, &ev(Key::KeyA, ctrl(), "'", '\'' as u32)), + None + ); +} + +#[test] +fn set_get_event_codepoint() { + let mut s = Set::new(); + s.parse_and_put("ctrl+'=new_window").unwrap(); + + // Matches on codepoint + assert_eq!( + ev_action(&s, &ev(Key::KeyA, ctrl(), "", '\'' as u32)), + Some(Action::NewWindow) + ); + + // Matches on UTF-8 + assert_eq!( + ev_action(&s, &ev(Key::KeyA, ctrl(), "'", 0)), + Some(Action::NewWindow) + ); + + // Doesn't match on physical + assert_eq!(ev_action(&s, &ev(Key::KeyA, ctrl(), "", 0)), None); +} + +#[test] +fn set_get_event_codepoint_case_folding() { + let mut s = Set::new(); + s.parse_and_put("ctrl+A=new_window").unwrap(); + + // Lowercase codepoint + assert_eq!( + ev_action(&s, &ev(Key::KeyJ, ctrl(), "", 'a' as u32)), + Some(Action::NewWindow) + ); + + // Uppercase codepoint + assert_eq!( + ev_action(&s, &ev(Key::KeyA, ctrl(), "", 'A' as u32)), + Some(Action::NewWindow) + ); + + // Negative case for sanity + assert_eq!(ev_action(&s, &ev(Key::KeyJ, ctrl(), "", 0)), None); +} + +#[test] +fn set_get_event_catch_all_fallback() { + let mut s = Set::new(); + s.parse_and_put("catch_all=ignore").unwrap(); + + // Matches unbound key without modifiers + assert_eq!( + ev_action(&s, &ev(Key::KeyA, Mods::default(), "", 0)), + Some(Action::Ignore) + ); + + // Matches unbound key with modifiers (falls back to catch_all without mods) + assert_eq!( + ev_action(&s, &ev(Key::KeyA, ctrl(), "", 0)), + Some(Action::Ignore) + ); + + // Specific binding takes precedence over catch_all + s.parse_and_put("ctrl+b=new_window").unwrap(); + assert_eq!( + ev_action(&s, &ev(Key::KeyB, ctrl(), "", 'b' as u32)), + Some(Action::NewWindow) + ); +} + +#[test] +fn set_get_event_catch_all_with_modifiers() { + let mut s = Set::new(); + s.parse_and_put("ctrl+catch_all=close_surface").unwrap(); + s.parse_and_put("catch_all=ignore").unwrap(); + + // Key with ctrl matches catch_all with ctrl modifier + assert_eq!( + ev_action(&s, &ev(Key::KeyA, ctrl(), "", 0)), + Some(Action::CloseSurface) + ); + + // Key without mods matches catch_all without mods + assert_eq!( + ev_action(&s, &ev(Key::KeyA, Mods::default(), "", 0)), + Some(Action::Ignore) + ); + + // Key with different mods falls back to catch_all without mods + let alt = Mods { + alt: true, + ..Mods::default() + }; + assert_eq!( + ev_action(&s, &ev(Key::KeyA, alt, "", 0)), + Some(Action::Ignore) + ); +} + +#[test] +fn action_clone() { + let a = Action::Ignore; + assert_eq!(a.clone(), Action::Ignore); + + let a = Action::Text("foo".into()); + assert_eq!(a.clone(), Action::Text("foo".into())); +} + +#[test] +fn parse_increase_font_size() { + assert_eq!( + ps("a=increase_font_size:1.5").action, + Action::IncreaseFontSize(1.5) + ); +} + +#[test] +fn parse_decrease_font_size() { + assert_eq!( + ps("a=decrease_font_size:2.5").action, + Action::DecreaseFontSize(2.5) + ); +} + +#[test] +fn parse_reset_font_size() { + assert_eq!(ps("a=reset_font_size").action, Action::ResetFontSize); +} + +#[test] +fn parse_set_font_size() { + assert_eq!(ps("a=set_font_size:13.5").action, Action::SetFontSize(13.5)); +} + +#[test] +fn parse_copy_to_clipboard_default() { + assert_eq!( + ps("a=copy_to_clipboard").action, + Action::CopyToClipboard(CopyToClipboard::Mixed) + ); +} + +#[test] +fn parse_copy_to_clipboard_explicit() { + assert_eq!( + ps("a=copy_to_clipboard:html").action, + Action::CopyToClipboard(CopyToClipboard::Html) + ); +} + +#[test] +fn parse_write_screen_file_no_format() { + assert_eq!( + ps("a=write_screen_file:copy").action, + Action::WriteScreenFile(WriteScreen::COPY) + ); +} + +#[test] +fn parse_write_screen_file_format() { + assert_eq!( + ps("a=write_screen_file:copy,html").action, + Action::WriteScreenFile(WriteScreen { + action: WriteScreenAction::Copy, + emit: WriteScreenFormat::Html, + }) + ); +} + +#[test] +fn parse_write_screen_file_format_as_string() { + let b = ps("a=write_screen_file:copy,html"); + assert_eq!(b.action.to_string(), "write_screen_file:copy,html"); +} + +#[test] +fn parse_write_screen_file_invalid() { + assert_eq!(ps_err("a=write_screen_file:"), INVALID); + assert_eq!(ps_err("a=write_screen_file:,"), INVALID); + assert_eq!(ps_err("a=write_screen_file:copy,"), INVALID); + assert_eq!(ps_err("a=write_screen_file:copy,html,extra"), INVALID); +} + +#[test] +fn action_format() { + let a = Action::Text("👻".into()); + assert_eq!(a.to_string(), "text:\\xf0\\x9f\\x91\\xbb"); +} + +#[test] +fn action_format_set_title() { + let a = Action::SetTabTitle("foo bar".into()); + assert_eq!(a.to_string(), "set_tab_title:foo bar"); +} + +#[test] +fn set_append_chain_with_no_parent_returns_error() { + let mut s = Set::new(); + assert_eq!( + s.append_chain(Action::NewTab), + Err(AppendChainError::NoChainParent) + ); +} + +#[test] +fn set_append_chain_after_put_converts_to_leaf_chained() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + s.append_chain(Action::NewTab).unwrap(); + + let chained = s.get(uni('a')).unwrap().as_leaf_chained().unwrap(); + assert_eq!(chained.actions.len(), 2); + assert_eq!(chained.actions[0], Action::NewWindow); + assert_eq!(chained.actions[1], Action::NewTab); +} + +#[test] +fn set_append_chain_after_put_flags_preserves_flags() { + let mut s = Set::new(); + s.put_flags( + uni('a'), + Action::NewWindow, + Flags { + consumed: false, + ..Flags::default() + }, + ); + s.append_chain(Action::NewTab).unwrap(); + + let chained = s.get(uni('a')).unwrap().as_leaf_chained().unwrap(); + assert!(!chained.flags.consumed); + assert_eq!(chained.actions.len(), 2); + assert_eq!(chained.actions[0], Action::NewWindow); + assert_eq!(chained.actions[1], Action::NewTab); +} + +#[test] +fn set_append_chain_multiple_times() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + s.append_chain(Action::NewTab).unwrap(); + s.append_chain(Action::CloseSurface).unwrap(); + + let chained = s.get(uni('a')).unwrap().as_leaf_chained().unwrap(); + assert_eq!(chained.actions.len(), 3); + assert_eq!(chained.actions[0], Action::NewWindow); + assert_eq!(chained.actions[1], Action::NewTab); + assert_eq!(chained.actions[2], Action::CloseSurface); +} + +#[test] +fn set_append_chain_removes_reverse_mapping() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + assert!(s.get_trigger(&Action::NewWindow).is_some()); + + // Chaining should remove the reverse mapping + s.append_chain(Action::NewTab).unwrap(); + assert!(s.get_trigger(&Action::NewWindow).is_none()); +} + +#[test] +fn set_append_chain_with_performable_does_not_affect_reverse_mapping() { + let mut s = Set::new(); + s.put(uni('b'), Action::NewWindow); + assert!(s.get_trigger(&Action::NewWindow).is_some()); + + // Add a performable binding (not in reverse map) and chain it + s.put_flags( + uni('a'), + Action::CloseSurface, + Flags { + performable: true, + ..Flags::default() + }, + ); + assert!(s.get_trigger(&Action::CloseSurface).is_none()); + + // Chaining the performable binding should not crash or affect anything + s.append_chain(Action::NewTab).unwrap(); + + // The non-performable new_window binding should still be in reverse map + assert!(s.get_trigger(&Action::NewWindow).is_some()); +} + +#[test] +fn set_append_chain_restores_next_valid_reverse_mapping() { + let mut s = Set::new(); + s.put(uni('a'), Action::NewWindow); + s.put(uni('b'), Action::NewWindow); + + // Reverse mapping should point to 'b' (most recent) + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('b' as u32) + ); + + // Chain an action to 'b', which should restore 'a' in the reverse map + s.append_chain(Action::NewTab).unwrap(); + assert_eq!( + s.get_trigger(&Action::NewWindow).unwrap().key, + TriggerKey::Unicode('a' as u32) + ); +} + +/// Stand-in for `formatterpkg.entryFormatter("keybind", ...)`. +struct KeybindFormatter(String); + +impl EntryFormatter for KeybindFormatter { + fn format_entry(&mut self, value: &str) { + self.0.push_str("keybind = "); + self.0.push_str(value); + self.0.push('\n'); + } +} + +fn format_set_entry(s: &Set, t: Trigger) -> String { + let (k, v) = s.get_entry(t).unwrap(); + let mut buf = k.to_string(); + let mut f = KeybindFormatter(String::new()); + v.format_entries(&mut buf, &mut f); + f.0 +} + +#[test] +fn set_format_entries_leaf_chained() { + let mut s = Set::new(); + s.parse_and_put("a=new_window").unwrap(); + s.parse_and_put("chain=new_tab").unwrap(); + + assert!(s.get(uni('a')).unwrap().as_leaf_chained().is_some()); + assert_eq!( + format_set_entry(&s, uni('a')), + "keybind = a=new_window\nkeybind = chain=new_tab\n" + ); +} + +#[test] +fn set_format_entries_leaf_chained_multiple_chains() { + let mut s = Set::new(); + s.parse_and_put("ctrl+a=new_window").unwrap(); + s.parse_and_put("chain=new_tab").unwrap(); + s.parse_and_put("chain=close_surface").unwrap(); + + let t = trig(ctrl(), TriggerKey::Unicode('a' as u32)); + assert_eq!( + s.get(t).unwrap().as_leaf_chained().unwrap().actions.len(), + 3 + ); + assert_eq!( + format_set_entry(&s, t), + "keybind = ctrl+a=new_window\nkeybind = chain=new_tab\nkeybind = chain=close_surface\n" + ); +} + +#[test] +fn set_format_entries_leaf_chained_with_text_action() { + let mut s = Set::new(); + s.parse_and_put("a=text:hello").unwrap(); + s.parse_and_put("chain=text:world").unwrap(); + + assert_eq!( + format_set_entry(&s, uni('a')), + "keybind = a=text:hello\nkeybind = chain=text:world\n" + ); +} + +#[test] +fn set_format_entries_sequence_shares_prefix() { + // Extra: sequences share the prefix and siblings are not corrupted by + // chained leaves. + let mut s = Set::new(); + s.parse_and_put("a>b=new_window").unwrap(); + s.parse_and_put("chain=new_tab").unwrap(); + s.parse_and_put("a>c=close_surface").unwrap(); + assert_eq!( + format_set_entry(&s, uni('a')), + "keybind = a>b=new_window\nkeybind = chain=new_tab\nkeybind = a>c=close_surface\n" + ); +} + +#[test] +fn trigger_hash_folds_case() { + assert_eq!(uni('a').hash(), uni('A').hash()); + assert!(uni('a').folded_equal(&uni('A'))); + assert!(!uni('a').equal(&uni('A'))); +} + +#[test] +fn trigger_display_roundtrip() { + for s in ["super+ctrl+alt+shift+a", "catch_all", "ctrl+arrow_up", "+"] { + let t = Trigger::parse(s).unwrap(); + assert_eq!(t.to_string(), s); + } +} + +#[test] +fn action_docs_and_scope() { + assert!( + Action::Ignore + .docs() + .starts_with("Ignore this key combination.") + ); + assert_eq!(Action::NewWindow.scope(), Scope::App); + assert_eq!(Action::NewTab.scope(), Scope::Surface); + assert!(Action::NewTab.scoped(Scope::App).is_none()); + assert!(Action::NewWindow.scoped(Scope::App).is_some()); +} + +#[test] +fn action_roundtrip_all_default_forms() { + for s in [ + "csi:0m", + "text:hello world", + "increase_font_size:1.5", + "scroll_to_row:5", + "goto_tab:3", + "move_tab:-1", + "adjust_selection:left", + "resize_split:up,10", + "close_tab:other", + "inspector:toggle", + "crash:main", + "activate_key_table:foo", + ] { + assert_eq!(Action::parse(s).unwrap().to_string(), s); + } +} + +#[test] +fn less_than_orders_by_mod_count() { + let a = bind( + trig(ctrl(), TriggerKey::Unicode('a' as u32)), + Action::Ignore, + ); + let b = bind(uni('a'), Action::Ignore); + assert!(less_than(&a, &b)); + assert!(!less_than(&b, &a)); +} diff --git a/crates/ghostty-input/src/command.rs b/crates/ghostty-input/src/command.rs new file mode 100644 index 0000000..e8e762d --- /dev/null +++ b/crates/ghostty-input/src/command.rs @@ -0,0 +1,590 @@ +//! Command palette entries (ZIG/src/input/command.zig). + +use std::sync::OnceLock; + +use crate::binding::{ + Action, CloseTabMode, CopyToClipboard, GotoWindow, InspectorMode, NavigateSearch, + SplitDirection, SplitFocusDirection, WriteScreen, WriteScreenAction, WriteScreenFormat, +}; + +/// A command is a named binding action that can be executed from +/// something like a command palette. +/// +/// A command must be associated with a binding; all commands can be +/// mapped to traditional `keybind` configurations. This restriction +/// makes it so that there is nothing special about commands and likewise +/// it makes it trivial and consistent to define custom commands. +/// +/// For apprt implementers: a command palette doesn't have to make use +/// of all the fields here. We try to provide as much information as +/// possible to make it easier to implement a command palette in the way +/// that makes the most sense for the application. +#[derive(Debug, Clone, PartialEq)] +pub struct Command { + pub action: Action, + pub title: String, + pub description: String, +} + +impl Command { + pub fn new(action: Action, title: &str, description: &str) -> Command { + Command { + action, + title: title.to_string(), + description: description.to_string(), + } + } + + pub fn equal(&self, other: &Command) -> bool { + if self.action.hash() != other.action.hash() { + return false; + } + self.title == other.title && self.description == other.description + } + + /// Implements a comparison function for sorting. The sorting is defined + /// by Ghostty to be what we prefer. If a caller wants some other + /// sorting, they should do it themselves. + pub fn less_than(lhs: &Command, rhs: &Command) -> bool { + cmp_ignore_case(&lhs.title, &rhs.title) == std::cmp::Ordering::Less + } + + /// The `ghostty_command_s` equivalent: (action key name, action string, + /// title, description). + pub fn cval(&self) -> CommandC { + CommandC { + action_key: self.action.name().to_string(), + action: self.action.to_string(), + title: self.title.clone(), + description: self.description.clone(), + } + } +} + +/// Owned form of `Command.C`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandC { + pub action_key: String, + pub action: String, + pub title: String, + pub description: String, +} + +/// `std.ascii.orderIgnoreCase` +fn cmp_ignore_case(a: &str, b: &str) -> std::cmp::Ordering { + let a = a.bytes().map(|c| c.to_ascii_lowercase()); + let b = b.bytes().map(|c| c.to_ascii_lowercase()); + a.cmp(b) +} + +/// The default command palette entries, sorted by title (ignoring case). +pub fn defaults() -> &'static [Command] { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| { + let mut result = default_commands(); + result.sort_by(|a, b| cmp_ignore_case(&a.title, &b.title)); + result + }) +} + +/// Defaults in C-compatible form. +pub fn defaults_c() -> Vec { + defaults().iter().map(Command::cval).collect() +} + +// The set of commands associated with each action key by default. Not all +// actions should have commands. As a general guideline, an action should have +// a command only if it is useful and reasonable to appear in a command +// palette. Actions without commands (parameterized actions without obvious +// values, ignore/unbind, etc.) are: csi, esc, cursor_key, set_font_size, +// set_surface_title, set_tab_title, search, scroll_to_row, +// scroll_page_fractional, scroll_page_lines, adjust_selection, jump_to_prompt, +// write_scrollback_file, goto_tab, resize_split, activate_key_table, +// activate_key_table_once, deactivate_key_table, deactivate_all_key_tables, +// end_key_sequence, crash, toggle_command_palette, toggle_quick_terminal, +// toggle_visibility, previous_tab, next_tab, last_tab, ignore, unbind. +fn default_commands() -> Vec { + vec![ + Command::new( + Action::Reset, + "Reset Terminal", + "Reset the terminal to a clean state.", + ), + Command::new( + Action::CopyToClipboard(CopyToClipboard::Mixed), + "Copy to Clipboard", + "Copy the selected text to the clipboard in both plain and styled formats.", + ), + Command::new( + Action::CopyToClipboard(CopyToClipboard::Plain), + "Copy Selection as Plain Text to Clipboard", + "Copy the selected text as plain text to the clipboard.", + ), + Command::new( + Action::CopyToClipboard(CopyToClipboard::Vt), + "Copy Selection as ANSI Sequences to Clipboard", + "Copy the selected text as ANSI escape sequences to the clipboard.", + ), + Command::new( + Action::CopyToClipboard(CopyToClipboard::Html), + "Copy Selection as HTML to Clipboard", + "Copy the selected text as HTML to the clipboard.", + ), + Command::new( + Action::CopyUrlToClipboard, + "Copy URL to Clipboard", + "Copy the URL under the cursor to the clipboard.", + ), + Command::new( + Action::CopyTitleToClipboard, + "Copy Terminal Title to Clipboard", + "Copy the terminal title to the clipboard. If the terminal title is not set this has no effect.", + ), + Command::new( + Action::PasteFromClipboard, + "Paste from Clipboard", + "Paste the contents of the main clipboard.", + ), + Command::new( + Action::PasteFromSelection, + "Paste from Selection", + "Paste the contents of the selection clipboard.", + ), + Command::new( + Action::StartSearch, + "Start Search", + "Start a search if one isn't already active.", + ), + Command::new( + Action::SearchSelection, + "Search Selection", + "Start a search for the current text selection.", + ), + Command::new( + Action::EndSearch, + "End Search", + "End the current search if any and hide any GUI elements.", + ), + Command::new( + Action::NavigateSearch(NavigateSearch::Next), + "Next Search Result", + "Navigate to the next search result, if any.", + ), + Command::new( + Action::NavigateSearch(NavigateSearch::Previous), + "Previous Search Result", + "Navigate to the previous search result, if any.", + ), + Command::new( + Action::IncreaseFontSize(1.0), + "Increase Font Size", + "Increase the font size by 1 point.", + ), + Command::new( + Action::DecreaseFontSize(1.0), + "Decrease Font Size", + "Decrease the font size by 1 point.", + ), + Command::new( + Action::ResetFontSize, + "Reset Font Size", + "Reset the font size to the default.", + ), + Command::new( + Action::ClearScreen, + "Clear Screen", + "Clear the screen and scrollback.", + ), + Command::new( + Action::SelectAll, + "Select All", + "Select all text on the screen.", + ), + Command::new( + Action::ScrollToTop, + "Scroll to Top", + "Scroll to the top of the screen.", + ), + Command::new( + Action::ScrollToBottom, + "Scroll to Bottom", + "Scroll to the bottom of the screen.", + ), + Command::new( + Action::ScrollToSelection, + "Scroll to Selection", + "Scroll to the selected text.", + ), + Command::new( + Action::ScrollPageUp, + "Scroll Page Up", + "Scroll the screen up by a page.", + ), + Command::new( + Action::ScrollPageDown, + "Scroll Page Down", + "Scroll the screen down by a page.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen::COPY), + "Copy Screen to Temporary File and Copy Path", + "Copy the screen contents to a temporary file and copy the path to the clipboard.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen::PASTE), + "Copy Screen to Temporary File and Paste Path", + "Copy the screen contents to a temporary file and paste the path to the file.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen::OPEN), + "Copy Screen to Temporary File and Open", + "Copy the screen contents to a temporary file and open it.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen { + action: WriteScreenAction::Copy, + emit: WriteScreenFormat::Html, + }), + "Copy Screen as HTML to Temporary File and Copy Path", + "Copy the screen contents as HTML to a temporary file and copy the path to the clipboard.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen { + action: WriteScreenAction::Paste, + emit: WriteScreenFormat::Html, + }), + "Copy Screen as HTML to Temporary File and Paste Path", + "Copy the screen contents as HTML to a temporary file and paste the path to the file.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen { + action: WriteScreenAction::Open, + emit: WriteScreenFormat::Html, + }), + "Copy Screen as HTML to Temporary File and Open", + "Copy the screen contents as HTML to a temporary file and open it.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen { + action: WriteScreenAction::Copy, + emit: WriteScreenFormat::Vt, + }), + "Copy Screen as ANSI Sequences to Temporary File and Copy Path", + "Copy the screen contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen { + action: WriteScreenAction::Paste, + emit: WriteScreenFormat::Vt, + }), + "Copy Screen as ANSI Sequences to Temporary File and Paste Path", + "Copy the screen contents as ANSI escape sequences to a temporary file and paste the path to the file.", + ), + Command::new( + Action::WriteScreenFile(WriteScreen { + action: WriteScreenAction::Open, + emit: WriteScreenFormat::Vt, + }), + "Copy Screen as ANSI Sequences to Temporary File and Open", + "Copy the screen contents as ANSI escape sequences to a temporary file and open it.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen::COPY), + "Copy Selection to Temporary File and Copy Path", + "Copy the selection contents to a temporary file and copy the path to the clipboard.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen::PASTE), + "Copy Selection to Temporary File and Paste Path", + "Copy the selection contents to a temporary file and paste the path to the file.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen::OPEN), + "Copy Selection to Temporary File and Open", + "Copy the selection contents to a temporary file and open it.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen { + action: WriteScreenAction::Copy, + emit: WriteScreenFormat::Html, + }), + "Copy Selection as HTML to Temporary File and Copy Path", + "Copy the selection contents as HTML to a temporary file and copy the path to the clipboard.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen { + action: WriteScreenAction::Paste, + emit: WriteScreenFormat::Html, + }), + "Copy Selection as HTML to Temporary File and Paste Path", + "Copy the selection contents as HTML to a temporary file and paste the path to the file.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen { + action: WriteScreenAction::Open, + emit: WriteScreenFormat::Html, + }), + "Copy Selection as HTML to Temporary File and Open", + "Copy the selection contents as HTML to a temporary file and open it.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen { + action: WriteScreenAction::Copy, + emit: WriteScreenFormat::Vt, + }), + "Copy Selection as ANSI Sequences to Temporary File and Copy Path", + "Copy the selection contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen { + action: WriteScreenAction::Paste, + emit: WriteScreenFormat::Vt, + }), + "Copy Selection as ANSI Sequences to Temporary File and Paste Path", + "Copy the selection contents as ANSI escape sequences to a temporary file and paste the path to the file.", + ), + Command::new( + Action::WriteSelectionFile(WriteScreen { + action: WriteScreenAction::Open, + emit: WriteScreenFormat::Vt, + }), + "Copy Selection as ANSI Sequences to Temporary File and Open", + "Copy the selection contents as ANSI escape sequences to a temporary file and open it.", + ), + Command::new(Action::NewWindow, "New Window", "Open a new window."), + Command::new(Action::NewTab, "New Tab", "Open a new tab."), + Command::new( + Action::MoveTab(-1), + "Move Tab Left", + "Move the current tab to the left.", + ), + Command::new( + Action::MoveTab(1), + "Move Tab Right", + "Move the current tab to the right.", + ), + Command::new( + Action::ToggleTabOverview, + "Toggle Tab Overview", + "Toggle the tab overview.", + ), + Command::new( + Action::PromptSurfaceTitle, + "Change Terminal Title…", + "Prompt for a new title for the current terminal.", + ), + Command::new( + Action::PromptTabTitle, + "Change Tab Title…", + "Prompt for a new title for the current tab.", + ), + Command::new( + Action::NewSplit(SplitDirection::Left), + "Split Left", + "Split the terminal to the left.", + ), + Command::new( + Action::NewSplit(SplitDirection::Right), + "Split Right", + "Split the terminal to the right.", + ), + Command::new( + Action::NewSplit(SplitDirection::Up), + "Split Up", + "Split the terminal up.", + ), + Command::new( + Action::NewSplit(SplitDirection::Down), + "Split Down", + "Split the terminal down.", + ), + Command::new( + Action::GotoSplit(SplitFocusDirection::Previous), + "Focus Split: Previous", + "Focus the previous split, if any.", + ), + Command::new( + Action::GotoSplit(SplitFocusDirection::Next), + "Focus Split: Next", + "Focus the next split, if any.", + ), + Command::new( + Action::GotoSplit(SplitFocusDirection::Left), + "Focus Split: Left", + "Focus the split to the left, if it exists.", + ), + Command::new( + Action::GotoSplit(SplitFocusDirection::Right), + "Focus Split: Right", + "Focus the split to the right, if it exists.", + ), + Command::new( + Action::GotoSplit(SplitFocusDirection::Up), + "Focus Split: Up", + "Focus the split above, if it exists.", + ), + Command::new( + Action::GotoSplit(SplitFocusDirection::Down), + "Focus Split: Down", + "Focus the split below, if it exists.", + ), + Command::new( + Action::GotoWindow(GotoWindow::Previous), + "Focus Window: Previous", + "Focus the previous window, if any.", + ), + Command::new( + Action::GotoWindow(GotoWindow::Next), + "Focus Window: Next", + "Focus the next window, if any.", + ), + Command::new( + Action::ToggleSplitZoom, + "Toggle Split Zoom", + "Toggle the zoom state of the current split.", + ), + Command::new( + Action::ToggleReadonly, + "Toggle Read-Only Mode", + "Toggle read-only mode for the current surface.", + ), + Command::new( + Action::EqualizeSplits, + "Equalize Splits", + "Equalize the size of all splits.", + ), + Command::new( + Action::ResetWindowSize, + "Reset Window Size", + "Reset the window size to the default.", + ), + Command::new( + Action::Inspector(InspectorMode::Toggle), + "Toggle Inspector", + "Toggle the inspector.", + ), + Command::new( + Action::ShowGtkInspector, + "Show the GTK Inspector", + "Show the GTK inspector.", + ), + Command::new( + Action::ShowOnScreenKeyboard, + "Show On-Screen Keyboard", + "Show the on-screen keyboard if present.", + ), + Command::new(Action::OpenConfig, "Open Config", "Open the config file."), + Command::new( + Action::ReloadConfig, + "Reload Config", + "Reload the config file.", + ), + Command::new( + Action::CloseSurface, + "Close Terminal", + "Close the current terminal.", + ), + Command::new( + Action::CloseTab(CloseTabMode::This), + "Close Tab", + "Close the current tab.", + ), + Command::new( + Action::CloseTab(CloseTabMode::Other), + "Close Other Tabs", + "Close all tabs in this window except the current one.", + ), + Command::new( + Action::CloseTab(CloseTabMode::Right), + "Close Tabs to the Right", + "Close all tabs to the right of the current one.", + ), + Command::new( + Action::CloseWindow, + "Close Window", + "Close the current window.", + ), + Command::new( + Action::CloseAllWindows, + "Close All Windows", + "Close all windows.", + ), + Command::new( + Action::ToggleMaximize, + "Toggle Maximize", + "Toggle the maximized state of the current window.", + ), + Command::new( + Action::ToggleFullscreen, + "Toggle Fullscreen", + "Toggle the fullscreen state of the current window.", + ), + Command::new( + Action::ToggleWindowDecorations, + "Toggle Window Decorations", + "Toggle the window decorations.", + ), + Command::new( + Action::ToggleWindowFloatOnTop, + "Toggle Float on Top", + "Toggle the float on top state of the current window.", + ), + Command::new( + Action::ToggleSecureInput, + "Toggle Secure Input", + "Toggle secure input mode.", + ), + Command::new( + Action::ToggleMouseReporting, + "Toggle Mouse Reporting", + "Toggle whether mouse events are reported to terminal applications.", + ), + Command::new( + Action::ToggleBackgroundOpacity, + "Toggle Background Opacity", + "Toggle the background opacity of a window that started transparent.", + ), + Command::new( + Action::CheckForUpdates, + "Check for Updates", + "Check for updates to the application.", + ), + Command::new(Action::Undo, "Undo", "Undo the last action."), + Command::new(Action::Redo, "Redo", "Redo the last undone action."), + Command::new(Action::Quit, "Quit", "Quit the application."), + Command::new( + Action::Text("👻".to_string()), + "Ghostty", + "Put a little Ghostty in your terminal.", + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_defaults() { + // This just ensures that defaults is analyzed and works. + assert_ne!(defaults().len(), 0); + assert_eq!(defaults().len(), defaults_c().len()); + } + + #[test] + fn command_defaults_sorted_and_roundtrip() { + let d = defaults(); + for w in d.windows(2) { + assert_ne!( + cmp_ignore_case(&w[0].title, &w[1].title), + std::cmp::Ordering::Greater + ); + } + // Round trip every default action through its string form. + // (`text` is stored raw and escaped on format, so it is excluded.) + for c in d.iter().filter(|c| !matches!(c.action, Action::Text(_))) { + let s = c.action.to_string(); + assert_eq!(Action::parse(&s).expect("parse"), c.action, "{s}"); + } + } +} diff --git a/crates/ghostty-input/src/config.rs b/crates/ghostty-input/src/config.rs new file mode 100644 index 0000000..02b55ef --- /dev/null +++ b/crates/ghostty-input/src/config.rs @@ -0,0 +1,13 @@ +//! Input-related config enums (ZIG/src/input/config.zig). + +/// Determines the macOS option key behavior. See the config +/// `macos-option-as-alt` for a lot more details. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[repr(i32)] +pub enum OptionAsAlt { + #[default] + False, + True, + Left, + Right, +} diff --git a/crates/ghostty-input/src/function_keys.rs b/crates/ghostty-input/src/function_keys.rs new file mode 100644 index 0000000..8657a8f --- /dev/null +++ b/crates/ghostty-input/src/function_keys.rs @@ -0,0 +1,616 @@ +//! This is the list of "PC style function keys" that xterm supports for +//! the legacy keyboard protocols. These always take priority since at the +//! time of writing this, even the most modern keyboard protocols still +//! are backwards compatible with regards to these sequences. +//! +//! This is based on a variety of sources cross-referenced but mostly +//! based on foot's keymap.h: https://codeberg.org/dnkl/foot/src/branch/master/keymap.h +//! (ZIG/src/input/function_keys.zig) + +use std::sync::LazyLock; + +use crate::key::{Key, Mods}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorMode { + Any, + Normal, + Application, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeypadMode { + Any, + Normal, + Application, +} + +/// A bit confusing so I'll document this one: this is the "modify other keys" +/// setting. We only change behavior for "set_other" which is ESC [ > 4; 2 m. +/// So this can be "any" which means we don't care what's going on. Or it +/// can be "set" which means modify keys must be set EXCEPT FOR "other keys" +/// mode, and "set_other" which means modify keys must be set to "other keys" +/// mode. +/// +/// See: https://invisible-island.net/xterm/modified-keys.html +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModifyKeys { + Any, + Set, + SetOther, +} + +/// A single entry in the table of keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// The exact set of modifiers that must be active for this entry to match. + /// If mods_empty_is_any is true then empty mods means any set of mods can + /// match. Otherwise, empty mods means no mods must be active. + pub mods: Mods, + pub mods_empty_is_any: bool, + + /// The state required for cursor/keypad mode. + pub cursor: CursorMode, + pub keypad: KeypadMode, + + /// Whether or not this entry should be used + pub modify_other_keys: ModifyKeys, + + /// The sequence to send to the pty if this entry matches. + pub sequence: String, +} + +impl Entry { + fn new(sequence: impl Into) -> Entry { + Entry { + mods: Mods::default(), + mods_empty_is_any: true, + cursor: CursorMode::Any, + keypad: KeypadMode::Any, + modify_other_keys: ModifyKeys::Any, + sequence: sequence.into(), + } + } +} + +const fn m(shift: bool, ctrl: bool, alt: bool, super_: bool) -> Mods { + Mods { + shift, + ctrl, + alt, + super_, + caps_lock: false, + num_lock: false, + sides: crate::key_mods::Side { + shift: crate::key_mods::ModSide::Left, + ctrl: crate::key_mods::ModSide::Left, + alt: crate::key_mods::ModSide::Left, + super_: crate::key_mods::ModSide::Left, + }, + } +} + +/// The list of modifier combinations for modify other key sequences. +/// The mode value is index + 2. +pub const MODIFIERS: &[Mods] = &[ + m(true, false, false, false), + m(false, false, true, false), + m(true, false, true, false), + m(false, true, false, false), + m(true, true, false, false), + m(false, true, true, false), + m(true, true, true, false), + m(false, false, false, true), + m(true, false, false, true), + m(false, false, true, true), + m(true, false, true, true), + m(false, true, false, true), + m(true, true, false, true), + m(false, true, true, true), + m(true, true, true, true), +]; + +fn ent(mods: Mods, modify_other_keys: ModifyKeys, sequence: &str) -> Entry { + Entry { + mods, + modify_other_keys, + ..Entry::new(sequence) + } +} + +/// Returns the entries for a key. Not every key has any entries. It is +/// expected that you'll just use a for loop through the entry values, there +/// are never more than a couple dozen or so. +pub fn keys(k: Key) -> &'static [Entry] { + &KEYS[k as usize] +} + +/// Iterate over the whole table (all keys, in `Key` order). +pub fn all_keys() -> impl Iterator { + KEYS.iter().map(Vec::as_slice) +} + +static KEYS: LazyLock>> = LazyLock::new(build_keys); + +fn cat(mut a: Vec, b: Vec) -> Vec { + a.extend(b); + a +} + +fn one(seq: &str) -> Vec { + vec![Entry::new(seq)] +} + +fn build_keys() -> Vec> { + let mut result: Vec> = vec![Vec::new(); Key::ALL.len()]; + let mut set = |k: Key, v: Vec| result[k as usize] = v; + + set( + Key::ArrowUp, + cat(pc_style("\x1b[1;{}A"), cursor_key("\x1b[A", "\x1bOA")), + ); + set( + Key::ArrowDown, + cat(pc_style("\x1b[1;{}B"), cursor_key("\x1b[B", "\x1bOB")), + ); + set( + Key::ArrowRight, + cat(pc_style("\x1b[1;{}C"), cursor_key("\x1b[C", "\x1bOC")), + ); + set( + Key::ArrowLeft, + cat(pc_style("\x1b[1;{}D"), cursor_key("\x1b[D", "\x1bOD")), + ); + set( + Key::Home, + cat(pc_style("\x1b[1;{}H"), cursor_key("\x1b[H", "\x1bOH")), + ); + set( + Key::End, + cat(pc_style("\x1b[1;{}F"), cursor_key("\x1b[F", "\x1bOF")), + ); + set(Key::Insert, cat(pc_style("\x1b[2;{}~"), one("\x1B[2~"))); + set(Key::Delete, cat(pc_style("\x1b[3;{}~"), one("\x1B[3~"))); + set(Key::PageUp, cat(pc_style("\x1b[5;{}~"), one("\x1B[5~"))); + set(Key::PageDown, cat(pc_style("\x1b[6;{}~"), one("\x1B[6~"))); + + // Function Keys. todo: f13-f35 but we need to add to input.Key + set(Key::F1, cat(pc_style("\x1b[1;{}P"), one("\x1BOP"))); + set(Key::F2, cat(pc_style("\x1b[1;{}Q"), one("\x1BOQ"))); + set(Key::F3, cat(pc_style("\x1b[13;{}~"), one("\x1BOR"))); + set(Key::F4, cat(pc_style("\x1b[1;{}S"), one("\x1BOS"))); + set(Key::F5, cat(pc_style("\x1b[15;{}~"), one("\x1B[15~"))); + set(Key::F6, cat(pc_style("\x1b[17;{}~"), one("\x1B[17~"))); + set(Key::F7, cat(pc_style("\x1b[18;{}~"), one("\x1B[18~"))); + set(Key::F8, cat(pc_style("\x1b[19;{}~"), one("\x1B[19~"))); + set(Key::F9, cat(pc_style("\x1b[20;{}~"), one("\x1B[20~"))); + set(Key::F10, cat(pc_style("\x1b[21;{}~"), one("\x1B[21~"))); + set(Key::F11, cat(pc_style("\x1b[23;{}~"), one("\x1B[23~"))); + set(Key::F12, cat(pc_style("\x1b[24;{}~"), one("\x1B[24~"))); + + // Keypad keys + set(Key::Numpad0, kp_keys("p")); + set(Key::Numpad1, kp_keys("q")); + set(Key::Numpad2, kp_keys("r")); + set(Key::Numpad3, kp_keys("s")); + set(Key::Numpad4, kp_keys("t")); + set(Key::Numpad5, kp_keys("u")); + set(Key::Numpad6, kp_keys("v")); + set(Key::Numpad7, kp_keys("w")); + set(Key::Numpad8, kp_keys("x")); + set(Key::Numpad9, kp_keys("y")); + set(Key::NumpadDecimal, kp_keys("n")); + set(Key::NumpadDivide, kp_keys("o")); + set(Key::NumpadMultiply, kp_keys("j")); + set(Key::NumpadSubtract, kp_keys("m")); + set(Key::NumpadAdd, kp_keys("k")); + set(Key::NumpadEnter, cat(kp_keys("M"), one("\r"))); + set( + Key::NumpadUp, + cat(pc_style("\x1b[1;{}A"), cursor_key("\x1b[A", "\x1bOA")), + ); + set( + Key::NumpadDown, + cat(pc_style("\x1b[1;{}B"), cursor_key("\x1b[B", "\x1bOB")), + ); + set( + Key::NumpadRight, + cat(pc_style("\x1b[1;{}C"), cursor_key("\x1b[C", "\x1bOC")), + ); + set( + Key::NumpadLeft, + cat(pc_style("\x1b[1;{}D"), cursor_key("\x1b[D", "\x1bOD")), + ); + set( + Key::NumpadBegin, + cat(pc_style("\x1b[1;{}E"), cursor_key("\x1b[E", "\x1bOE")), + ); + set( + Key::NumpadHome, + cat(pc_style("\x1b[1;{}H"), cursor_key("\x1b[H", "\x1bOH")), + ); + set( + Key::NumpadEnd, + cat(pc_style("\x1b[1;{}F"), cursor_key("\x1b[F", "\x1bOF")), + ); + set( + Key::NumpadInsert, + cat(pc_style("\x1b[2;{}~"), one("\x1B[2~")), + ); + set( + Key::NumpadDelete, + cat(pc_style("\x1b[3;{}~"), one("\x1B[3~")), + ); + set( + Key::NumpadPageUp, + cat(pc_style("\x1b[5;{}~"), one("\x1B[5~")), + ); + set( + Key::NumpadPageDown, + cat(pc_style("\x1b[6;{}~"), one("\x1B[6~")), + ); + + set( + Key::Backspace, + vec![ + // Modify Keys Normal / Other + ent(m(true, false, false, false), ModifyKeys::Set, "\x7f"), + ent(m(false, false, true, false), ModifyKeys::Set, "\x1b\x7f"), + ent(m(true, false, true, false), ModifyKeys::Set, "\x1b\x7f"), + ent(m(true, true, false, false), ModifyKeys::Set, "\x08"), + ent(m(false, true, true, false), ModifyKeys::Set, "\x1b\x08"), + ent(m(false, false, false, true), ModifyKeys::Set, "\x7f"), + ent(m(true, false, false, true), ModifyKeys::Set, "\x7f"), + ent(m(false, false, true, true), ModifyKeys::Set, "\x1b\x7f"), + ent(m(true, false, true, true), ModifyKeys::Set, "\x1b\x7f"), + ent(m(false, true, false, true), ModifyKeys::Set, "\x08"), + ent(m(true, true, false, true), ModifyKeys::Set, "\x08"), + ent(m(false, true, true, true), ModifyKeys::Set, "\x1b\x08"), + ent(m(true, true, true, true), ModifyKeys::Set, "\x1b\x08"), + ent( + m(true, false, false, false), + ModifyKeys::SetOther, + "\x1b[27;2;127~", + ), + ent( + m(false, false, true, false), + ModifyKeys::SetOther, + "\x1b[27;3;127~", + ), + ent( + m(true, false, true, false), + ModifyKeys::SetOther, + "\x1b[27;4;127~", + ), + ent( + m(true, true, false, false), + ModifyKeys::SetOther, + "\x1b[27;6;127~", + ), + ent( + m(false, true, true, false), + ModifyKeys::SetOther, + "\x1b[27;7;127~", + ), + ent( + m(true, true, true, false), + ModifyKeys::SetOther, + "\x1b[27;8;127~", + ), + ent( + m(false, false, false, true), + ModifyKeys::SetOther, + "\x1b[27;9;127~", + ), + ent( + m(true, false, false, true), + ModifyKeys::SetOther, + "\x1b[27;10;127~", + ), + ent( + m(false, false, true, true), + ModifyKeys::SetOther, + "\x1b[27;11;127~", + ), + ent( + m(true, false, true, true), + ModifyKeys::SetOther, + "\x1b[27;12;127~", + ), + ent( + m(false, true, false, true), + ModifyKeys::SetOther, + "\x1b[27;13;127~", + ), + ent( + m(true, true, false, true), + ModifyKeys::SetOther, + "\x1b[27;14;127~", + ), + ent( + m(false, true, true, true), + ModifyKeys::SetOther, + "\x1b[27;15;127~", + ), + ent( + m(true, true, true, true), + ModifyKeys::SetOther, + "\x1b[27;16;127~", + ), + ent(m(false, true, false, false), ModifyKeys::Any, "\x08"), + ent(m(false, false, false, false), ModifyKeys::Any, "\x7f"), + ent(m(false, true, false, false), ModifyKeys::Any, "\x08"), + ent(m(false, false, false, false), ModifyKeys::Any, "\x7f"), + ], + ); + + set( + Key::Tab, + vec![ + ent(m(true, false, false, false), ModifyKeys::Set, "\x1b[Z"), + ent(m(false, false, true, false), ModifyKeys::Set, "\x1b\t"), + ent( + m(true, false, false, false), + ModifyKeys::SetOther, + "\x1b[27;2;9~", + ), + ent( + m(false, false, true, false), + ModifyKeys::SetOther, + "\x1b[27;3;9~", + ), + ent(m(true, false, true, false), ModifyKeys::Any, "\x1b[27;4;9~"), + ent( + m(false, true, false, false), + ModifyKeys::Any, + "\x1b[27;5;9~", + ), + ent(m(true, true, false, false), ModifyKeys::Any, "\x1b[27;6;9~"), + ent(m(false, true, true, false), ModifyKeys::Any, "\x1b[27;7;9~"), + ent(m(true, true, true, false), ModifyKeys::Any, "\x1b[27;8;9~"), + ent( + m(false, false, false, true), + ModifyKeys::Any, + "\x1b[27;9;9~", + ), + ent( + m(true, false, false, true), + ModifyKeys::Any, + "\x1b[27;10;9~", + ), + ent( + m(false, false, true, true), + ModifyKeys::Any, + "\x1b[27;11;9~", + ), + ent(m(true, false, true, true), ModifyKeys::Any, "\x1b[27;12;9~"), + ent( + m(false, true, false, true), + ModifyKeys::Any, + "\x1b[27;13;9~", + ), + ent(m(true, true, false, true), ModifyKeys::Any, "\x1b[27;14;9~"), + ent(m(false, true, true, true), ModifyKeys::Any, "\x1b[27;15;9~"), + ent(m(true, true, true, true), ModifyKeys::Any, "\x1b[27;16;9~"), + ent(m(false, false, false, false), ModifyKeys::Any, "\t"), + ent(m(false, false, false, false), ModifyKeys::Any, "\t"), + ], + ); + + set( + Key::Enter, + vec![ + ent( + m(true, false, false, false), + ModifyKeys::Any, + "\x1b[27;2;13~", + ), + ent(m(false, false, true, false), ModifyKeys::Set, "\x1b\r"), + ent( + m(false, false, true, false), + ModifyKeys::SetOther, + "\x1b[27;3;13~", + ), + ent( + m(true, false, true, false), + ModifyKeys::Any, + "\x1b[27;4;13~", + ), + ent( + m(false, true, false, false), + ModifyKeys::Any, + "\x1b[27;5;13~", + ), + ent( + m(true, true, false, false), + ModifyKeys::Any, + "\x1b[27;6;13~", + ), + ent( + m(false, true, true, false), + ModifyKeys::Any, + "\x1b[27;7;13~", + ), + ent(m(true, true, true, false), ModifyKeys::Any, "\x1b[27;8;13~"), + ent( + m(false, false, false, true), + ModifyKeys::Any, + "\x1b[27;9;13~", + ), + ent( + m(true, false, false, true), + ModifyKeys::Any, + "\x1b[27;10;13~", + ), + ent( + m(false, false, true, true), + ModifyKeys::Any, + "\x1b[27;11;13~", + ), + ent( + m(true, false, true, true), + ModifyKeys::Any, + "\x1b[27;12;13~", + ), + ent( + m(false, true, false, true), + ModifyKeys::Any, + "\x1b[27;13;13~", + ), + ent( + m(true, true, false, true), + ModifyKeys::Any, + "\x1b[27;14;13~", + ), + ent( + m(false, true, true, true), + ModifyKeys::Any, + "\x1b[27;15;13~", + ), + ent(m(true, true, true, true), ModifyKeys::Any, "\x1b[27;16;13~"), + ent(m(false, false, false, false), ModifyKeys::Any, "\r"), + ent(m(false, false, false, false), ModifyKeys::Any, "\r"), + ], + ); + + set( + Key::Escape, + vec![ + ent( + m(true, false, false, false), + ModifyKeys::Any, + "\x1b[27;2;27~", + ), + ent(m(false, false, true, false), ModifyKeys::Any, "\x1b\x1b"), + ent( + m(true, false, true, false), + ModifyKeys::Any, + "\x1b[27;4;27~", + ), + ent( + m(false, true, false, false), + ModifyKeys::Any, + "\x1b[27;5;27~", + ), + ent( + m(true, true, false, false), + ModifyKeys::Any, + "\x1b[27;6;27~", + ), + ent( + m(false, true, true, false), + ModifyKeys::Any, + "\x1b[27;7;27~", + ), + ent(m(true, true, true, false), ModifyKeys::Any, "\x1b[27;8;27~"), + ent( + m(false, false, false, true), + ModifyKeys::Any, + "\x1b[27;9;27~", + ), + ent( + m(true, false, false, true), + ModifyKeys::Any, + "\x1b[27;10;27~", + ), + ent( + m(false, false, true, true), + ModifyKeys::Any, + "\x1b[27;11;27~", + ), + ent( + m(true, false, true, true), + ModifyKeys::Any, + "\x1b[27;12;27~", + ), + ent( + m(false, true, false, true), + ModifyKeys::Any, + "\x1b[27;13;27~", + ), + ent( + m(true, true, false, true), + ModifyKeys::Any, + "\x1b[27;14;27~", + ), + ent( + m(false, true, true, true), + ModifyKeys::Any, + "\x1b[27;15;27~", + ), + ent(m(true, true, true, true), ModifyKeys::Any, "\x1b[27;16;27~"), + ent(m(false, false, false, false), ModifyKeys::Any, "\x1b"), + ent(m(false, false, false, false), ModifyKeys::Any, "\x1b"), + ], + ); + + result +} + +/// Returns the default keypad application mode entry. +fn kp_default(suffix: &str) -> Vec { + vec![Entry { + mods_empty_is_any: false, + keypad: KeypadMode::Application, + ..Entry::new(format!("\x1bO{suffix}")) + }] +} + +/// Returns the entries for a keypad key. The suffix is the final character +/// of the sent sequence, such as "r" for kp_2. +fn kp_keys(suffix: &str) -> Vec { + let mut pc = pc_style(&format!("\x1bO{{}}{suffix}")); + for entry in &mut pc { + entry.keypad = KeypadMode::Application; + } + cat(kp_default(suffix), pc) +} + +/// Returns entries that are dependent on cursor key settings. +fn cursor_key(normal: &str, application: &str) -> Vec { + vec![ + Entry { + cursor: CursorMode::Normal, + ..Entry::new(normal) + }, + Entry { + cursor: CursorMode::Application, + ..Entry::new(application) + }, + ] +} + +/// Constructs a set of pcStyle function keys using the given format. The +/// format should have exactly one "hole" (`{}`) for the mods code. +/// Example: "\x1b[11;{}~" for F1. +fn pc_style(fmt: &str) -> Vec { + MODIFIERS + .iter() + .zip(2..) + .map(|(mods, code)| Entry { + mods: *mods, + ..Entry::new(fmt.replace("{}", &code.to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_table() { + // All key sequences must fit into a termio array. + // TODO(integration): termio's `Message.WriteReq.Small.Max` isn't + // available here; use its documented limit (38 bytes). + const MAX: usize = 38; + let mut n = 0; + for entries in all_keys() { + for entry in entries { + assert!(entry.sequence.len() <= MAX, "{:?}", entry.sequence); + n += 1; + } + } + assert!(n > 0); + } +} diff --git a/crates/ghostty-input/src/helpgen_actions.rs b/crates/ghostty-input/src/helpgen_actions.rs new file mode 100644 index 0000000..30d6003 --- /dev/null +++ b/crates/ghostty-input/src/helpgen_actions.rs @@ -0,0 +1,148 @@ +//! This module is a help generator for keybind actions documentation. +//! It can generate documentation in different formats (plaintext for CLI, +//! markdown for website) while maintaining consistent content. +//! (ZIG/src/input/helpgen_actions.zig) +//! +//! The docs themselves live in [`ActionKey::docs`] (the Zig version reads +//! them from the build-time generated `help_strings`). + +use crate::binding::ActionKey; + +/// Format options for generating keybind actions documentation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// Plain text output with indentation + Plaintext, + /// Markdown formatted output + Markdown, +} + +impl Format { + fn format_field_name(self, w: &mut String, field_name: &str) { + match self { + Format::Plaintext => { + w.push_str(field_name); + w.push_str(":\n"); + } + Format::Markdown => { + w.push_str("## `"); + w.push_str(field_name); + w.push_str("`\n"); + } + } + } + + fn format_doc_line(self, w: &mut String, line: &str) { + match self { + Format::Plaintext => { + w.push_str(" "); + w.push_str(line); + w.push('\n'); + } + Format::Markdown => { + w.push_str(line); + w.push('\n'); + } + } + } + + fn header(self) -> Option<&'static str> { + match self { + Format::Plaintext => None, + Format::Markdown => Some( + "---\n\ + title: Keybinding Action Reference\n\ + description: Reference of all Ghostty keybinding actions.\n\ + editOnGithubLink: https://github.com/ghostty-org/ghostty/edit/main/src/input/Binding.zig\n\ + ---\n\ + \n\ + This is a reference of all Ghostty keybinding actions.\n\ + \n\ + \n", + ), + } + } +} + +/// Generate keybind actions documentation with the specified format +pub fn generate(writer: &mut String, format: Format, show_docs: bool) { + if let Some(header) = format.header() { + writer.push_str(header); + } + + let mut stream = String::new(); + + for &key in ActionKey::ALL { + let name = key.name(); + if name.starts_with('_') { + continue; + } + + // Write previously stored doc comment below all related actions + // (every action has a doc comment). + if show_docs { + writer.push_str(&stream); + writer.push('\n'); + stream.clear(); + } + + if show_docs { + format.format_field_name(writer, name); + } else { + writer.push_str(name); + writer.push('\n'); + } + + if show_docs { + let mut iter = key.docs().split('\n').peekable(); + while let Some(s) = iter.next() { + // If it is the last line and empty, then skip it. + if iter.peek().is_none() && s.is_empty() { + continue; + } + format.format_doc_line(&mut stream, s); + } + } + } + + // Write any remaining buffered documentation + if !stream.is_empty() { + writer.push_str(&stream); + } +} + +/// Convenience: generate into a fresh string. +pub fn generate_string(format: Format, show_docs: bool) -> String { + let mut s = String::new(); + generate(&mut s, format, show_docs); + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plaintext_with_docs_lists_all_actions() { + let s = generate_string(Format::Plaintext, true); + assert!(s.starts_with("\nignore:\n")); + assert!(s.contains("ignore:\n Ignore this key combination.\n")); + for &k in ActionKey::ALL { + assert!(s.contains(&format!("{}:\n", k.name()))); + } + } + + #[test] + fn names_only() { + let s = generate_string(Format::Plaintext, false); + assert_eq!(s.lines().count(), ActionKey::ALL.len()); + assert_eq!(s.lines().next(), Some("ignore")); + } + + #[test] + fn markdown_header_and_field() { + let s = generate_string(Format::Markdown, true); + assert!(s.starts_with("---\ntitle: Keybinding Action Reference\n")); + assert!(s.contains("## `unbind`\n")); + } +} diff --git a/crates/ghostty-input/src/key.rs b/crates/ghostty-input/src/key.rs new file mode 100644 index 0000000..ff42d88 --- /dev/null +++ b/crates/ghostty-input/src/key.rs @@ -0,0 +1,611 @@ +//! Key events and key codes (ZIG/src/input/key.zig). +//! +//! The Zig `imguiKey` helper (cimgui constants) is intentionally not ported; +//! the inspector is deferred (see notes). + +pub use crate::key_mods::Mods; + +/// A generic key input event. This is the information that is necessary +/// regardless of apprt in order to generate the proper terminal +/// control sequences for a given key press. +/// +/// Some apprts may not be able to provide all of this information, such +/// as GLFW. In this case, the apprt should provide as much information +/// as it can and it should be expected that the terminal behavior +/// will not be totally correct. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyEvent<'a> { + /// The action: press, release, etc. + pub action: Action, + + /// The keycode of the physical key that was pressed. This is agnostic + /// to the layout. Layout-dependent matching can only be done via the + /// UTF-8 or unshifted codepoint. + pub key: Key, + + /// Mods are the modifiers that are pressed. + pub mods: Mods, + + /// The mods that were consumed in order to generate the text + /// in utf8. This has the mods set that were consumed, so to + /// get the set of mods that are effective you must negate + /// mods with this. + /// + /// This field is meaningless if utf8 is empty. + pub consumed_mods: Mods, + + /// Composing is true when this key event is part of a dead key + /// composition sequence and we're in the middle of it. + pub composing: bool, + + /// The utf8 sequence that was generated by this key event. + /// This will be an empty string if there is no text generated. + /// If composing is true and this is non-empty, this is preedit + /// text. + pub utf8: &'a str, + + /// The codepoint for this key when it is unshifted. For example, + /// shift+a is "A" in UTF-8 but unshifted would provide 'a'. + pub unshifted_codepoint: u32, +} + +impl Default for KeyEvent<'_> { + fn default() -> Self { + KeyEvent { + action: Action::Press, + key: Key::Unidentified, + mods: Mods::default(), + consumed_mods: Mods::default(), + composing: false, + utf8: "", + unshifted_codepoint: 0, + } + } +} + +impl KeyEvent<'_> { + /// Returns the effective modifiers for this event. The effective + /// modifiers are the mods that should be considered for keybindings. + pub fn effective_mods(&self) -> Mods { + if self.utf8.is_empty() { + return self.mods; + } + self.mods.unset(self.consumed_mods) + } + + /// Returns a unique hash for this key event to be used for tracking + /// uniquess specifically with bindings. This omits fields that are + /// irrelevant for bindings. + pub fn binding_hash(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + + // These are all the fields that are explicitly part of Trigger. + self.key.hash(&mut hasher); + self.unshifted_codepoint.hash(&mut hasher); + self.mods.binding().hash(&mut hasher); + + // Notes on unmapped things and why: + // + // - action: we don't have action-specific bindings right now + // AND we want to know if a key resulted in a binding regardless + // of action because a press should also ignore a release and so on. + // + // We can add to this if there is other confusion. + + hasher.finish() + } +} + +/// The action associated with an input event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum Action { + Release, + Press, + Repeat, +} + +macro_rules! define_keys { + ($($variant:ident => $name:literal,)*) => { + /// The set of key codes that Ghostty is aware of. These represent + /// physical keys on the keyboard. The logical key (or key string) + /// is the string that is generated by the key event and that is up + /// to the apprt to provide. + /// + /// Note that these are layout-independent. For example, the "a" + /// key on a US keyboard is the same as the "ф" key on a Russian + /// keyboard, but both will report the "a" enum value in the key + /// event. These values are based on the W3C standard. See: + /// https://www.w3.org/TR/uievents-code + /// + /// Layout-dependent strings are provided in the KeyEvent struct as + /// UTF-8 and are produced by the associated apprt. Ghostty core has + /// no mechanism to map input events to strings without the apprt. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + #[repr(i32)] + pub enum Key { + $($variant,)* + } + + impl Key { + /// All keys in declaration order. + pub const ALL: &'static [Key] = &[$(Key::$variant,)*]; + + /// The Zig tag name (snake_case) of this key. + pub fn name(self) -> &'static str { + match self { + $(Key::$variant => $name,)* + } + } + + /// Look up a key by its Zig tag name (snake_case). + pub fn from_name(s: &str) -> Option { + match s { + $($name => Some(Key::$variant),)* + _ => None, + } + } + } + }; +} + +define_keys! { + Unidentified => "unidentified", + Backquote => "backquote", + Backslash => "backslash", + BracketLeft => "bracket_left", + BracketRight => "bracket_right", + Comma => "comma", + Digit0 => "digit_0", + Digit1 => "digit_1", + Digit2 => "digit_2", + Digit3 => "digit_3", + Digit4 => "digit_4", + Digit5 => "digit_5", + Digit6 => "digit_6", + Digit7 => "digit_7", + Digit8 => "digit_8", + Digit9 => "digit_9", + Equal => "equal", + IntlBackslash => "intl_backslash", + IntlRo => "intl_ro", + IntlYen => "intl_yen", + KeyA => "key_a", + KeyB => "key_b", + KeyC => "key_c", + KeyD => "key_d", + KeyE => "key_e", + KeyF => "key_f", + KeyG => "key_g", + KeyH => "key_h", + KeyI => "key_i", + KeyJ => "key_j", + KeyK => "key_k", + KeyL => "key_l", + KeyM => "key_m", + KeyN => "key_n", + KeyO => "key_o", + KeyP => "key_p", + KeyQ => "key_q", + KeyR => "key_r", + KeyS => "key_s", + KeyT => "key_t", + KeyU => "key_u", + KeyV => "key_v", + KeyW => "key_w", + KeyX => "key_x", + KeyY => "key_y", + KeyZ => "key_z", + Minus => "minus", + Period => "period", + Quote => "quote", + Semicolon => "semicolon", + Slash => "slash", + AltLeft => "alt_left", + AltRight => "alt_right", + Backspace => "backspace", + CapsLock => "caps_lock", + ContextMenu => "context_menu", + ControlLeft => "control_left", + ControlRight => "control_right", + Enter => "enter", + MetaLeft => "meta_left", + MetaRight => "meta_right", + ShiftLeft => "shift_left", + ShiftRight => "shift_right", + Space => "space", + Tab => "tab", + Convert => "convert", + KanaMode => "kana_mode", + NonConvert => "non_convert", + Delete => "delete", + End => "end", + Help => "help", + Home => "home", + Insert => "insert", + PageDown => "page_down", + PageUp => "page_up", + ArrowDown => "arrow_down", + ArrowLeft => "arrow_left", + ArrowRight => "arrow_right", + ArrowUp => "arrow_up", + NumLock => "num_lock", + Numpad0 => "numpad_0", + Numpad1 => "numpad_1", + Numpad2 => "numpad_2", + Numpad3 => "numpad_3", + Numpad4 => "numpad_4", + Numpad5 => "numpad_5", + Numpad6 => "numpad_6", + Numpad7 => "numpad_7", + Numpad8 => "numpad_8", + Numpad9 => "numpad_9", + NumpadAdd => "numpad_add", + NumpadBackspace => "numpad_backspace", + NumpadClear => "numpad_clear", + NumpadClearEntry => "numpad_clear_entry", + NumpadComma => "numpad_comma", + NumpadDecimal => "numpad_decimal", + NumpadDivide => "numpad_divide", + NumpadEnter => "numpad_enter", + NumpadEqual => "numpad_equal", + NumpadMemoryAdd => "numpad_memory_add", + NumpadMemoryClear => "numpad_memory_clear", + NumpadMemoryRecall => "numpad_memory_recall", + NumpadMemoryStore => "numpad_memory_store", + NumpadMemorySubtract => "numpad_memory_subtract", + NumpadMultiply => "numpad_multiply", + NumpadParenLeft => "numpad_paren_left", + NumpadParenRight => "numpad_paren_right", + NumpadSubtract => "numpad_subtract", + NumpadSeparator => "numpad_separator", + NumpadUp => "numpad_up", + NumpadDown => "numpad_down", + NumpadRight => "numpad_right", + NumpadLeft => "numpad_left", + NumpadBegin => "numpad_begin", + NumpadHome => "numpad_home", + NumpadEnd => "numpad_end", + NumpadInsert => "numpad_insert", + NumpadDelete => "numpad_delete", + NumpadPageUp => "numpad_page_up", + NumpadPageDown => "numpad_page_down", + Escape => "escape", + F1 => "f1", + F2 => "f2", + F3 => "f3", + F4 => "f4", + F5 => "f5", + F6 => "f6", + F7 => "f7", + F8 => "f8", + F9 => "f9", + F10 => "f10", + F11 => "f11", + F12 => "f12", + F13 => "f13", + F14 => "f14", + F15 => "f15", + F16 => "f16", + F17 => "f17", + F18 => "f18", + F19 => "f19", + F20 => "f20", + F21 => "f21", + F22 => "f22", + F23 => "f23", + F24 => "f24", + F25 => "f25", + Fn => "fn", + FnLock => "fn_lock", + PrintScreen => "print_screen", + ScrollLock => "scroll_lock", + Pause => "pause", + BrowserBack => "browser_back", + BrowserFavorites => "browser_favorites", + BrowserForward => "browser_forward", + BrowserHome => "browser_home", + BrowserRefresh => "browser_refresh", + BrowserSearch => "browser_search", + BrowserStop => "browser_stop", + Eject => "eject", + LaunchApp1 => "launch_app_1", + LaunchApp2 => "launch_app_2", + LaunchMail => "launch_mail", + MediaPlayPause => "media_play_pause", + MediaSelect => "media_select", + MediaStop => "media_stop", + MediaTrackNext => "media_track_next", + MediaTrackPrevious => "media_track_previous", + Power => "power", + Sleep => "sleep", + AudioVolumeDown => "audio_volume_down", + AudioVolumeMute => "audio_volume_mute", + AudioVolumeUp => "audio_volume_up", + WakeUp => "wake_up", + Copy => "copy", + Cut => "cut", + Paste => "paste", +} + +/// Codepoint <-> key map. Keypad entries must always be last so that +/// `from_ascii` doesn't map them over normal numerics and other keys. +const CODEPOINT_MAP: &[(u32, Key)] = &[ + ('a' as u32, Key::KeyA), + ('b' as u32, Key::KeyB), + ('c' as u32, Key::KeyC), + ('d' as u32, Key::KeyD), + ('e' as u32, Key::KeyE), + ('f' as u32, Key::KeyF), + ('g' as u32, Key::KeyG), + ('h' as u32, Key::KeyH), + ('i' as u32, Key::KeyI), + ('j' as u32, Key::KeyJ), + ('k' as u32, Key::KeyK), + ('l' as u32, Key::KeyL), + ('m' as u32, Key::KeyM), + ('n' as u32, Key::KeyN), + ('o' as u32, Key::KeyO), + ('p' as u32, Key::KeyP), + ('q' as u32, Key::KeyQ), + ('r' as u32, Key::KeyR), + ('s' as u32, Key::KeyS), + ('t' as u32, Key::KeyT), + ('u' as u32, Key::KeyU), + ('v' as u32, Key::KeyV), + ('w' as u32, Key::KeyW), + ('x' as u32, Key::KeyX), + ('y' as u32, Key::KeyY), + ('z' as u32, Key::KeyZ), + ('0' as u32, Key::Digit0), + ('1' as u32, Key::Digit1), + ('2' as u32, Key::Digit2), + ('3' as u32, Key::Digit3), + ('4' as u32, Key::Digit4), + ('5' as u32, Key::Digit5), + ('6' as u32, Key::Digit6), + ('7' as u32, Key::Digit7), + ('8' as u32, Key::Digit8), + ('9' as u32, Key::Digit9), + (';' as u32, Key::Semicolon), + (' ' as u32, Key::Space), + ('\'' as u32, Key::Quote), + (',' as u32, Key::Comma), + ('`' as u32, Key::Backquote), + ('.' as u32, Key::Period), + ('/' as u32, Key::Slash), + ('-' as u32, Key::Minus), + ('=' as u32, Key::Equal), + ('[' as u32, Key::BracketLeft), + (']' as u32, Key::BracketRight), + ('\\' as u32, Key::Backslash), + // Control characters + ('\t' as u32, Key::Tab), + // Keypad entries. We just assume keypad with the numpad_ prefix + // so that has some special meaning. + ('0' as u32, Key::Numpad0), + ('1' as u32, Key::Numpad1), + ('2' as u32, Key::Numpad2), + ('3' as u32, Key::Numpad3), + ('4' as u32, Key::Numpad4), + ('5' as u32, Key::Numpad5), + ('6' as u32, Key::Numpad6), + ('7' as u32, Key::Numpad7), + ('8' as u32, Key::Numpad8), + ('9' as u32, Key::Numpad9), + ('.' as u32, Key::NumpadDecimal), + ('/' as u32, Key::NumpadDivide), + ('*' as u32, Key::NumpadMultiply), + ('-' as u32, Key::NumpadSubtract), + ('+' as u32, Key::NumpadAdd), + ('=' as u32, Key::NumpadEqual), +]; + +impl Key { + /// Converts an ASCII character to a key, if possible. This returns + /// None if the character is unknown. + /// + /// Note that this can't distinguish between physical keys, i.e. '0' + /// may be from the number row or the keypad, but it always maps + /// to '.zero'. + /// + /// This is what we want, we want people to create keybindings that + /// are independent of the physical key. + pub fn from_ascii(ch: u8) -> Option { + CODEPOINT_MAP + .iter() + // No ASCII characters should ever map to a keypad key + .filter(|e| !e.1.keypad()) + .find(|e| e.0 == u32::from(ch)) + .map(|e| e.1) + } + + /// Converts a W3C key code to a Ghostty key enum value. + /// + /// All required W3C key codes are supported, but there are a number of + /// non-standard key codes that are not supported. In the case the value is + /// invalid or unsupported, this function will return None. + pub fn from_w3c(code: &str) -> Option { + // If the code is bigger than our buffer it can't possibly match. + if code.len() > 128 { + return None; + } + + // First just check the whole thing lowercased, this is the simple case + if let Some(k) = Key::from_name(&code.to_ascii_lowercase()) { + return Some(k); + } + + // We need to convert FooBar to foo_bar + let mut result = String::new(); + for (i, ch) in code.bytes().enumerate() { + match ch { + b'a'..=b'z' => result.push(ch as char), + + // Caps and numbers trigger underscores + b'A'..=b'Z' | b'0'..=b'9' => { + if i > 0 { + result.push('_'); + } + result.push(ch.to_ascii_lowercase() as char); + } + + // We don't know of any key codes that aren't alphanumeric. + _ => return None, + } + } + + Key::from_name(&result) + } + + /// Converts a Ghostty key enum value to a W3C key code. + pub fn w3c(self) -> String { + let name = self.name(); + let mut out = String::with_capacity(name.len()); + let mut upper_next = true; + for ch in name.chars() { + if ch == '_' { + upper_next = true; + } else if upper_next { + out.push(ch.to_ascii_uppercase()); + upper_next = false; + } else { + out.push(ch); + } + } + out + } + + /// True if this key represents a printable character. + pub fn printable(self) -> bool { + CODEPOINT_MAP.iter().any(|e| e.1 == self) + } + + /// True if this key is a modifier. + pub fn modifier(self) -> bool { + matches!( + self, + Key::ShiftLeft + | Key::ControlLeft + | Key::AltLeft + | Key::MetaLeft + | Key::ShiftRight + | Key::ControlRight + | Key::AltRight + | Key::MetaRight + ) + } + + /// Whether this key should be remappable by the operating system. + /// + /// On certain OSes (namely Linux and the BSDs) certain keys like the + /// functional keys are expected to be remappable by the user, such as + /// in the very common use case of swapping the Caps Lock key with the + /// Escape key with the XKB option `caps:swapescape`. + /// + /// However, the way XKB implements this is by essentially acting as a + /// software key remapper that destroys all information about the original + /// physical key, leading to very annoying bugs like #7309 where the + /// physical key `XKB_KEY_c` gets remapped into `XKB_KEY_Cyrillic_tse`, + /// which causes all of our physical key handling to completely break down. + /// _Very naughty._ + /// + /// As a compromise, given that writing system keys (§3.1.1) comprise the + /// majority of keys that "change meaning [...] based on the current locale + /// and keyboard layout", we allow all other keys to be remapped by default + /// since they should be fairly harmless. We might consider making this + /// configurable, but for now this should at least placate most people. + pub fn should_be_remappable(self) -> bool { + let v = self as i32; + // "Writing System Keys" § 3.1.1 is the contiguous range + // backquote ..= slash. + !(Key::Backquote as i32..=Key::Slash as i32).contains(&v) + } + + /// Returns true if this is a keypad key. + pub fn keypad(self) -> bool { + self.name().starts_with("numpad_") + } + + /// Returns the codepoint representing this key, or None if the key is not + /// printable. + pub fn codepoint(self) -> Option { + CODEPOINT_MAP.iter().find(|e| e.1 == self).map(|e| e.0) + } + + /// true if this key is one of the left or right versions of super (MacOS) + /// or ctrl. + pub fn ctrl_or_super(self) -> bool { + // Non-darwin target. + self == Key::ControlLeft || self == Key::ControlRight + } + + /// true if this key is either left or right shift. + pub fn left_or_right_shift(self) -> bool { + self == Key::ShiftLeft || self == Key::ShiftRight + } + + /// true if this key is either left or right alt. + pub fn left_or_right_alt(self) -> bool { + self == Key::AltLeft || self == Key::AltRight + } +} + +/// This sets either "ctrl" or "super" to true (but not both) +/// on mods depending on if the build target is Mac or not. On +/// Mac, we default to super (i.e. super+c for copy) and on +/// non-Mac we default to ctrl (i.e. ctrl+c for copy). +pub fn ctrl_or_super(mods: Mods) -> Mods { + let mut copy = mods; + // Non-darwin target. + copy.ctrl = true; + copy +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn from_ascii_should_not_return_keypad_keys() { + assert_eq!(Key::from_ascii(b'0'), Some(Key::Digit0)); + assert_eq!(Key::from_ascii(b'*'), None); + } + + #[test] + fn keypad_keys() { + assert!(Key::Numpad0.keypad()); + assert!(!Key::Digit1.keypad()); + } + + #[test] + fn w3c() { + // All our keys should convert to and from the W3C format. + for &key in Key::ALL { + assert_eq!(Some(key), Key::from_w3c(&key.w3c()), "{key:?}"); + } + } + + #[test] + fn w3c_names() { + assert_eq!(Key::KeyA.w3c(), "KeyA"); + assert_eq!(Key::ArrowUp.w3c(), "ArrowUp"); + assert_eq!(Key::Fn.w3c(), "Fn"); + } + + #[test] + fn should_be_remappable_range() { + assert!(!Key::KeyA.should_be_remappable()); + assert!(!Key::Slash.should_be_remappable()); + assert!(Key::AltLeft.should_be_remappable()); + assert!(Key::Unidentified.should_be_remappable()); + } + + #[test] + fn ctrl_or_super_test() { + let m = ctrl_or_super(Mods::default()); + assert!(m.ctrl_or_super()); + } +} diff --git a/crates/ghostty-input/src/key_encode.rs b/crates/ghostty-input/src/key_encode.rs new file mode 100644 index 0000000..5922293 --- /dev/null +++ b/crates/ghostty-input/src/key_encode.rs @@ -0,0 +1,3161 @@ +//! Key event encoding: legacy (xterm/fixterms) and Kitty keyboard protocol +//! (ZIG/src/input/key_encode.zig). + +use std::io::{self, Write}; + +use ghostty_vt::kitty::key::Flags as KittyFlags; + +use crate::{ + config::OptionAsAlt, + function_keys::{self, CursorMode, KeypadMode, ModifyKeys}, + key::{self, Key, KeyEvent}, + kitty::{self as kitty_table, Entry as KittyEntry}, +}; + +/// Options that affect key encoding behavior. This is a mix of behavior +/// from terminal state as well as application configuration. +/// +/// TODO(integration): Zig has `Options.fromTerminal(*const Terminal)`; the +/// terminal crate should construct this from its modes (`alt_esc_prefix`, +/// `cursor_keys`, `keypad_keys`, `ignore_keypad_with_numlock`, flag +/// `modify_other_keys_2`, active screen `kitty_keyboard.current()`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Options { + /// Terminal DEC mode 1 + pub cursor_key_application: bool, + + /// Terminal DEC mode 66 + pub keypad_key_application: bool, + + /// Terminal DEC mode 1035 + pub ignore_keypad_with_numlock: bool, + + /// Terminal DEC mode 1036 + pub alt_esc_prefix: bool, + + /// xterm "modifyOtherKeys mode 2". Details here: + /// https://invisible-island.net/xterm/modified-keys.html + pub modify_other_keys_state_2: bool, + + /// Kitty keyboard protocol flags. + pub kitty_flags: KittyFlags, + + /// Determines whether the "option" key on macOS is treated + /// as "alt" or not. See the Ghostty `macos_option-as-alt` config + /// docs for a more detailed description of why this is needed. + pub macos_option_as_alt: OptionAsAlt, +} + +impl Options { + pub const DEFAULT: Options = Options { + cursor_key_application: false, + keypad_key_application: false, + ignore_keypad_with_numlock: false, + alt_esc_prefix: false, + modify_other_keys_state_2: false, + kitty_flags: KittyFlags::DISABLED, + macos_option_as_alt: OptionAsAlt::False, + }; +} + +/// Encode the key event to the writer in the proper format given +/// the options. For example, this will properly encode a key press +/// such as "ctrl+A" to Kitty format if Kitty encoding is enabled. +/// +/// Not all key events will result in output. It is up to the caller +/// to use a writer that can track whether any output was written if +/// they care about that. +pub fn encode(writer: &mut W, event: &KeyEvent, opts: &Options) -> io::Result<()> { + if opts.kitty_flags.int() != 0 { + kitty(writer, event, opts) + } else { + legacy(writer, event, opts) + } +} + +fn is_enter_backspace_tab(k: Key) -> bool { + matches!(k, Key::Enter | Key::Backspace | Key::Tab) +} + +/// Perform Kitty keyboard protocol encoding of the key event. +fn kitty(writer: &mut W, event: &KeyEvent, opts: &Options) -> io::Result<()> { + // This should never happen but we'll check anyway. + if opts.kitty_flags.int() == 0 { + return legacy(writer, event, opts); + } + + // We only processed "press" events unless report events is active + if event.action == key::Action::Release { + if !opts.kitty_flags.report_events { + return Ok(()); + } + + // Enter, backspace, and tab do not report release events unless "report + // all" is set + if !opts.kitty_flags.report_all && is_enter_backspace_tab(event.key) { + return Ok(()); + } + } + + let all_mods = event.mods; + let effective_mods = event.effective_mods(); + let binding_mods = effective_mods.binding(); + + // Find the entry for this key in the kitty table. + let entry_: Option = 'entry: { + // Functional or predefined keys + for entry in kitty_table::entries() { + if entry.key == event.key { + break 'entry Some(*entry); + } + } + + // Otherwise, we use our unicode codepoint from UTF8. We + // always use the unshifted value. + if event.unshifted_codepoint > 0 { + break 'entry Some(KittyEntry { + key: event.key, + code: event.unshifted_codepoint, + final_: b'u', + modifier: false, + }); + } + + None + }; + + 'preprocessing: { + // When composing, the only keys sent are plain modifiers. + if event.composing { + if let Some(entry) = &entry_ + && entry.modifier + { + break 'preprocessing; + } + + return Ok(()); + } + + // IME confirmation still sends an enter key so if we have enter + // and UTF8 text we just send it directly since we assume that is + // what's happening. See legacy()'s similar logic for more details + // on how to verify this. + if !event.utf8.is_empty() && matches!(event.key, Key::Enter | Key::Backspace) { + // See legacy for why we handle this this way. + if !is_control_utf8(event.utf8) { + if event.key == Key::Backspace { + return Ok(()); + } + return writer.write_all(event.utf8.as_bytes()); + } + } + + // If we're reporting all then we always send CSI sequences. + if !opts.kitty_flags.report_all { + // Quote: + // The only exceptions are the Enter, Tab and Backspace keys which + // still generate the same bytes as in legacy mode this is to allow the + // user to type and execute commands in the shell such as reset after a + // program that sets this mode crashes without clearing it. + // + // Quote ("report all" mode): + // Note that all keys are reported as escape codes, including Enter, + // Tab, Backspace etc. + if binding_mods.empty() { + match event.key { + Key::Enter => return writer.write_all(b"\r"), + Key::Tab => return writer.write_all(b"\t"), + Key::Backspace => return writer.write_all(&[0x7F]), + _ => {} + } + } + + // Send plain-text non-modified text directly to the terminal. + // We don't send release events because those are specially encoded. + if !event.utf8.is_empty() + && binding_mods.empty() + && event.action != key::Action::Release + { + // We only do this for printable characters. We should + // inspect the real unicode codepoint properties here but + // the real world issue is usually control characters. + if !event.utf8.chars().any(|c| is_control(u32::from(c))) { + return writer.write_all(event.utf8.as_bytes()); + } + } + } + } + + let Some(entry) = entry_ else { + // No entry found. If we have UTF-8 text this is a pure text event + // (e.g. composed/IME text), so send it as-is so programs can + // still receive it. + if !event.utf8.is_empty() { + return writer.write_all(event.utf8.as_bytes()); + } + return Ok(()); + }; + + // If this is just a modifier we require "report all" to send the sequence. + if entry.modifier && !opts.kitty_flags.report_all { + return Ok(()); + } + + let seq = { + let mut seq = KittySequence { + key: entry.code, + final_: entry.final_, + mods: KittyMods::from_input(event.action, event.key, all_mods), + event: KittyEvent::None, + alternates: [None, None], + text: "", + }; + + if opts.kitty_flags.report_events { + seq.event = match event.action { + key::Action::Press => KittyEvent::Press, + key::Action::Release => KittyEvent::Release, + key::Action::Repeat => KittyEvent::Repeat, + }; + } + + 'alternates: { + if !opts.kitty_flags.report_alternates { + break 'alternates; + } + + // Break early if this is a control key + if is_control(seq.key) { + break 'alternates; + } + + let mut it = event.utf8.chars(); + + // If we have a codepoint in our UTF-8 sequence, then we can + // report the shifted version. + if let Some(cp1) = it.next().map(u32::from) { + // Set the first alternate (shifted version) + if cp1 != seq.key && seq.mods.shift { + seq.alternates[0] = Some(cp1); + } + + // We want to know if there are additional codepoints because + // our logic below depends on the utf8 being a single codepoint. + let has_cp2 = it.next().is_some(); + + // Set the base layout key. We only report this if this codepoint + // differs from our pressed key. + if let Some(base) = event.key.codepoint() + && base != seq.key + && (cp1 != base && !has_cp2) + { + seq.alternates[1] = Some(base); + } + } else { + // No UTF-8 so we can't report a shifted key but we can still + // report a base layout key. + if let Some(base) = event.key.codepoint() + && base != seq.key + { + seq.alternates[1] = Some(base); + } + } + } + + if opts.kitty_flags.report_associated && seq.event != KittyEvent::Release { + // Determine if the Alt modifier should be treated as an actual + // modifier (in which case it prevents associated text) or as + // the macOS Option key, which does not prevent associated text. + // (Non-macOS: always true.) + let alt_prevents_text = true; + + if !seq.mods.prevents_text(alt_prevents_text) { + seq.text = event.utf8; + } + } + + seq + }; + + seq.encode(writer) +} + +/// Perform legacy encoding of the key event. "Legacy" in this case +/// is referring to the behavior of traditional terminals, plus +/// xterm's `modifyOtherKeys`, plus Paul Evans's "fixterms" spec. +/// These together combine the legacy protocol because they're all +/// meant to be extensions that do not change any existing behavior +/// and therefore safe to combine. +fn legacy(writer: &mut W, event: &KeyEvent, opts: &Options) -> io::Result<()> { + let all_mods = event.mods; + let effective_mods = event.effective_mods(); + let binding_mods = effective_mods.binding(); + + // Legacy encoding only does press/repeat + if event.action != key::Action::Press && event.action != key::Action::Repeat { + return Ok(()); + } + + // If we're in a dead key state then we never emit a sequence. + if event.composing { + return Ok(()); + } + + // If we match a PC style function key then that is our result. + if let Some(sequence) = pc_style_function_key( + event.key, + all_mods, + opts.cursor_key_application, + opts.keypad_key_application, + opts.ignore_keypad_with_numlock, + opts.modify_other_keys_state_2, + ) { + // If we have UTF-8 text, then we never emit PC style function + // keys. Many function keys (escape, enter, backspace) have + // a specific meaning when dead keys are active and so we don't + // want to send that to the terminal. Examples: + // + // - Japanese: escape clears the dead key state + // - Korean: escape commits the dead key state + // - Korean: backspace should delete a single preedit char + // + let mut skip_pc_style = false; + if !event.utf8.is_empty() + && matches!(event.key, Key::Backspace | Key::Enter | Key::Escape) + // We want to ignore control characters. This is because + // some apprts (macOS) will send control characters as + // UTF-8 encodings and we handle that manually. + && !is_control_utf8(event.utf8) + { + // Backspace encodes nothing because we modified IME. + // Enter/escape don't encode the PC-style encoding + // because we want to encode committed text. + if event.key == Key::Backspace { + return Ok(()); + } + skip_pc_style = true; + } + + if !skip_pc_style { + return writer.write_all(sequence.as_bytes()); + } + } + + // If we match a control sequence, we output that directly. For + // ctrlSeq we have to use all mods because we want it to only + // match ctrl+. + if let Some(ch) = ctrl_seq(event.key, event.utf8, event.unshifted_codepoint, all_mods) { + // C0 sequences support alt-as-esc prefixing. + if binding_mods.alt { + return writer.write_all(&[0x1B, ch]); + } + + return writer.write_all(&[ch]); + } + + // If we have no UTF8 text then the only possibility is the + // alt-prefix handling of unshifted codepoints... so we process that. + let utf8 = event.utf8; + if utf8.is_empty() { + if let Some(byte) = legacy_alt_prefix(event, binding_mods, all_mods, opts) { + writer.write_all(&[0x1B, byte])?; + } + return Ok(()); + } + + // In modify other keys state 2, we send the CSI 27 sequence + // for any char with a modifier. Ctrl sequences like Ctrl+a + // are already handled above. + if opts.modify_other_keys_state_2 { + let mut it = utf8.chars(); + // We only do this if we have a single codepoint. There shouldn't + // ever be a multi-codepoint sequence that triggers this. + if let (Some(codepoint), None) = (it.next().map(u32::from), it.clone().next()) { + // The mods we encode for this are just the binding mods (shift, ctrl, + // super, alt unless it is actually option). + let mods = event.mods.binding(); + + // This copies xterm's `ModifyOtherKeys` function that returns + // whether modify other keys should be encoded for the given + // input. + let should_modify = 'should_modify: { + // xterm IsControlInput + if (0x40..=0x7F).contains(&codepoint) { + break 'should_modify true; + } + + // If we have anything other than shift pressed, encode. + let mut mods_no_shift = mods; + mods_no_shift.shift = false; + if !mods_no_shift.empty() { + break 'should_modify true; + } + + // We only have shift pressed. We only allow space. + if codepoint == u32::from(' ') { + break 'should_modify true; + } + + // This logic isn't complete but I don't fully understand + // the rest so I'm going to wait until we can have a + // reasonable test scenario. + false + }; + + if should_modify { + for (modset, code) in function_keys::MODIFIERS.iter().zip(2..) { + if !mods.equal(*modset) { + continue; + } + return write!(writer, "\x1B[27;{code};{codepoint}~"); + } + } + } + } + + // Let's see if we should apply fixterms to this codepoint. + // At this stage of key processing, we only need to apply fixterms + // to unicode codepoints if we have ctrl set. + if event.mods.ctrl { + 'csiu: { + // Important: we want to use the original mods here, not the + // effective mods. The fixterms spec states the shifted chars + // should be sent uppercase but Kitty changes that behavior + // so we'll send all the mods. + let mut csi_u_mods = CsiUMods::from_input(event.mods); + + // Get our codepoint. If we have more than one codepoint this + // can't be valid CSIu. + let mut it = event.utf8.chars(); + let Some(first) = it.next() else { + break 'csiu; + }; + if it.next().is_some() { + break 'csiu; + } + let mut ch = u32::from(first); + + // If our character is A to Z and we have shift set, then + // we lowercase it. This is a Kitty-specific behavior that + // we choose to follow and diverge from the fixterms spec. + // This makes it easier for programs to detect shifted letters + // for keybindings and is not just theoretical but used by + // real programs. + if (u32::from('A')..=u32::from('Z')).contains(&ch) && csi_u_mods.shift { + // We want to rely on apprt to send us the correct + // unshifted codepoint... + ch += 32; + } + + // If our unshifted codepoint is identical to the shifted + // then we consider shift. Otherwise, we do not because the + // shift key was used to obtain the character. This is specified + // by fixterms. + if event.unshifted_codepoint != ch { + csi_u_mods.shift = false; + } + + return write!(writer, "\x1B[{};{}u", ch, csi_u_mods.seq_int()); + } + } + + // If we have alt-pressed and alt-esc-prefix is enabled, then + // we need to prefix the utf8 sequence with an esc. + if let Some(byte) = legacy_alt_prefix(event, binding_mods, all_mods, opts) { + return writer.write_all(&[0x1B, byte]); + } + + // (On macOS, command+keys do not encode text. On Linux we continue to + // encode text because it is typical.) + + writer.write_all(utf8.as_bytes()) +} + +fn legacy_alt_prefix( + event: &KeyEvent, + binding_mods: key::Mods, + _mods: key::Mods, + opts: &Options, +) -> Option { + // This only takes effect with alt pressed + if !binding_mods.alt || !opts.alt_esc_prefix { + return None; + } + + // (macOS option-as-alt handling does not apply on Linux.) + + // Otherwise, we require utf8 to already have the byte represented. + let utf8 = event.utf8.as_bytes(); + if utf8.len() == 1 { + return Some(utf8[0]); + } + + // If UTF8 isn't set, we will allow unshifted codepoints through. + if event.unshifted_codepoint > 0 + && let Ok(byte) = u8::try_from(event.unshifted_codepoint) + { + return Some(byte); + } + + // Else, we can't figure out the byte to alt-prefix so we + // exit this handling. + None +} + +/// Determines whether the key should be encoded in the xterm +/// "PC-style Function Key" syntax (roughly). This is a hardcoded +/// table of keys and modifiers that result in a specific sequence. +fn pc_style_function_key( + keyval: Key, + mods: key::Mods, + cursor_key_application: bool, + keypad_key_application_req: bool, + ignore_keypad_with_numlock: bool, + modify_other_keys: bool, // True if state 2 +) -> Option<&'static str> { + // We only want binding-sensitive mods because lock keys + // and directional modifiers (left/right) don't matter for + // pc-style function keys. + let mods_int = mods.binding().int(); + + // Keypad application keymode isn't super straightforward. + // On xterm, in VT220 mode, numlock alone is enough to trigger + // application mode. But in more modern modes, numlock is + // ignored by default via mode 1035 (default true). If mode + // 1035 is on, we always are in numerical keypad mode. If + // mode 1035 is off, we are in application mode if the + // proper numlock state is pressed. The numlock state is implicitly + // determined based on the keycode sent (i.e. 1 with numlock + // on will be kp_end). + // + // If we're ignoring keypad then this is always false. + // In other words, we're always in numerical keypad mode. + let keypad_key_application = if ignore_keypad_with_numlock { + false + } else { + keypad_key_application_req + }; + + for entry in function_keys::keys(keyval) { + match entry.cursor { + CursorMode::Any => {} + CursorMode::Normal => { + if cursor_key_application { + continue; + } + } + CursorMode::Application => { + if !cursor_key_application { + continue; + } + } + } + + match entry.keypad { + KeypadMode::Any => {} + KeypadMode::Normal => { + if keypad_key_application { + continue; + } + } + KeypadMode::Application => { + if !keypad_key_application { + continue; + } + } + } + + match entry.modify_other_keys { + ModifyKeys::Any => {} + ModifyKeys::Set => { + if modify_other_keys { + continue; + } + } + ModifyKeys::SetOther => { + if !modify_other_keys { + continue; + } + } + } + + let entry_mods_int = entry.mods.int(); + if entry_mods_int == 0 { + if mods_int != 0 && !entry.mods_empty_is_any { + continue; + } + // mods are either empty, or empty means any so we allow it. + } else if entry_mods_int != mods_int { + // any set mods require an exact match + continue; + } + + return Some(entry.sequence.as_str()); + } + + None +} + +/// Returns the C0 byte for the key event if it should be used. +/// This converts a key event into the expected terminal behavior +/// such as Ctrl+C turning into 0x03, amongst many other translations. +/// +/// This will return None if the key event should not be converted +/// into a C0 byte. There are many cases for this and you should read +/// the source code to understand them. +fn ctrl_seq(logical_key: Key, utf8: &str, unshifted_codepoint: u32, mods: key::Mods) -> Option { + let ctrl_only = key::Mods { + ctrl: true, + ..key::Mods::default() + } + .int(); + + // If ctrl is not pressed then we never do anything. + if !mods.ctrl { + return None; + } + + // We need to only get binding modifiers so we strip lock + // keys, sides, etc. + let mut unset_mods = mods.binding(); + + // Remove alt from our modifiers because it does not impact whether + // we are generating a ctrl sequence and we handle the ESC-prefix + // logic separately. + unset_mods.alt = false; + + let mut ch: u8 = 'ch: { + // If we have exactly one UTF8 byte, we assume that is the + // character we want to convert to a C0 byte. + if utf8.len() == 1 { + break 'ch utf8.as_bytes()[0]; + } + + // If we have a logical key that maps to a single byte + // printable character, we use that. History to explain this: + // this was added to support cyrillic keyboard layouts such + // as Russian and Mongolian. These layouts have a `c` key that + // maps to U+0441 (cyrillic small letter "c") but every + // terminal I've tested encodes this as ctrl+c. + if let Some(cp) = logical_key.codepoint() + && let Ok(byte) = u8::try_from(cp) + { + // For this specific case, we only map to the key if + // we have exactly ctrl pressed. This is because shift + // would modify the key and we don't know how to do that + // properly here (don't have the layout). And we want + // to encode shift as CSIu. + if unset_mods.int() != ctrl_only { + return None; + } + break 'ch byte; + } + + // Otherwise we don't have a character to convert that + // we can reliably map to a C0 byte. + return None; + }; + + // Remove shift if we have something outside of the US letter + // range. This is so that characters such as `ctrl+shift+-` + // generate the correct ctrl-seq (used by emacs). + if unset_mods.shift && !ch.is_ascii_uppercase() { + // Special case for fixterms awkward case as specified. + if ch != b'@' { + unset_mods.shift = false; + } + } + + // If the character is uppercase, we convert it to lowercase. We + // rely on the unshifted codepoint to do this. This handles + // the scenario where we have caps lock pressed. Note that + // shifted characters are handled above, if we are just pressing + // shift then the ctrl-only check will fail later and we won't + // ctrl-seq encode. + if ch.is_ascii_uppercase() + && unshifted_codepoint > 0 + && let Ok(byte) = u8::try_from(unshifted_codepoint) + { + ch = byte; + } + + // An additional note on caps lock and shift interaction. + // If we have caps lock set and an ASCII letter is pressed, + // we lowercase it (above). If we have only control pressed, + // we process it as a ctrl seq. For example ctrl+M with caps + // lock but no shift will encode as 0x0D. + // + // But, if you press ctrl+shift+m, this will not encode as a + // ctrl-seq and falls through to CSIu encoding. This lets programs + // detect the difference between ctrl+M and ctrl+shift+M. This + // diverges from the fixterms "spec" and most terminals. This + // only matches Kitty in behavior. But I believe this is a + // justified divergence because it's a useful distinction. + + // After unsetting, we only continue if we have ONLY control set. + if unset_mods.int() != ctrl_only { + return None; + } + + // From Kitty's key encoding logic. I tried to discern the exact + // behavior across different terminals but it's not clear, so I'm + // just going to repeat what Kitty does. + match ch { + b' ' => Some(0), + b'/' => Some(31), + b'0' => Some(48), + b'1' => Some(49), + b'2' => Some(0), + b'3' => Some(27), + b'4' => Some(28), + b'5' => Some(29), + b'6' => Some(30), + b'7' => Some(31), + b'8' => Some(127), + b'9' => Some(57), + b'?' => Some(127), + b'@' => Some(0), + b'\\' => Some(28), + b']' => Some(29), + b'^' => Some(30), + b'_' => Some(31), + b'a' => Some(1), + b'b' => Some(2), + b'c' => Some(3), + b'd' => Some(4), + b'e' => Some(5), + b'f' => Some(6), + b'g' => Some(7), + b'h' => Some(8), + b'j' => Some(10), + b'k' => Some(11), + b'l' => Some(12), + b'n' => Some(14), + b'o' => Some(15), + b'p' => Some(16), + b'q' => Some(17), + b'r' => Some(18), + b's' => Some(19), + b't' => Some(20), + b'u' => Some(21), + b'v' => Some(22), + b'w' => Some(23), + b'x' => Some(24), + b'y' => Some(25), + b'z' => Some(26), + b'~' => Some(30), + + // These are purposely NOT handled here because of the fixterms + // specification: https://www.leonerd.org.uk/hacks/fixterms/ + // These are processed as CSI u. + // 'i' => 0x09, + // 'm' => 0x0D, + // '[' => 0x1B, + _ => None, + } +} + +/// Returns true if this is an ASCII control character, matches libc implementation. +fn is_control(cp: u32) -> bool { + cp < 0x20 || cp == 0x7F +} + +/// Returns true if this string is comprised of a single +/// control character. This returns false for multi-byte strings. +fn is_control_utf8(s: &str) -> bool { + s.len() == 1 && is_control(u32::from(s.as_bytes()[0])) +} + +/// This is the bitmask for fixterm CSI u modifiers. +#[derive(Debug, Clone, Copy, Default)] +struct CsiUMods { + shift: bool, + alt: bool, + ctrl: bool, +} + +impl CsiUMods { + /// Convert an input mods value into the CSI u mods value. + fn from_input(mods: key::Mods) -> CsiUMods { + CsiUMods { + shift: mods.shift, + alt: mods.alt, + ctrl: mods.ctrl, + } + } + + /// Returns the raw int value of this packed struct. + fn int(self) -> u8 { + u8::from(self.shift) | u8::from(self.alt) << 1 | u8::from(self.ctrl) << 2 + } + + /// Returns the integer value sent as part of the CSI u sequence. + /// This adds 1 to the bitmask value as described in the spec. + fn seq_int(self) -> u8 { + self.int() + 1 + } +} + +/// This is the bitfields for Kitty modifiers. +#[derive(Debug, Clone, Copy, Default)] +#[allow(dead_code)] +struct KittyMods { + shift: bool, + alt: bool, + ctrl: bool, + super_: bool, + hyper: bool, + meta: bool, + caps_lock: bool, + num_lock: bool, +} + +impl KittyMods { + /// Convert an input mods value into the CSI u mods value. + fn from_input(_action: key::Action, _k: Key, mods: key::Mods) -> KittyMods { + KittyMods { + shift: mods.shift, + alt: mods.alt, + ctrl: mods.ctrl, + super_: mods.super_, + hyper: false, + meta: false, + caps_lock: mods.caps_lock, + num_lock: mods.num_lock, + } + } + + /// Returns true if the modifiers prevent printable text. + /// + /// The alt_prevents_text parameter determines whether or not the Alt + /// modifier prevents printable text. On Linux, this is always true. On + /// macOS, this is only true if macos-option-as-alt is set. + fn prevents_text(self, alt_prevents_text: bool) -> bool { + (self.alt && alt_prevents_text) || self.ctrl || self.super_ || self.hyper || self.meta + } + + /// Returns the raw int value of this packed struct. + fn int(self) -> u16 { + u16::from(self.shift) + | u16::from(self.alt) << 1 + | u16::from(self.ctrl) << 2 + | u16::from(self.super_) << 3 + | u16::from(self.hyper) << 4 + | u16::from(self.meta) << 5 + | u16::from(self.caps_lock) << 6 + | u16::from(self.num_lock) << 7 + } + + /// Returns the integer value sent as part of the Kitty sequence. + /// This adds 1 to the bitmask value as described in the spec. + fn seq_int(self) -> u16 { + self.int() + 1 + } +} + +/// Values for the event code (see "event-type" in the KittySequence comment). +/// Note that Kitty omits the ":1" for the press event but other +/// terminals include it. We'll include it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KittyEvent { + None = 0, + Press = 1, + Repeat = 2, + Release = 3, +} + +/// Represents a kitty key sequence and has helpers for encoding it. +/// The sequence from the Kitty specification: +/// +/// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u +#[derive(Debug, Clone, Copy)] +struct KittySequence<'a> { + key: u32, + final_: u8, + mods: KittyMods, + event: KittyEvent, + alternates: [Option; 2], + text: &'a str, +} + +impl KittySequence<'_> { + fn encode(&self, writer: &mut W) -> io::Result<()> { + if self.final_ == b'u' || self.final_ == b'~' { + return self.encode_full(writer); + } + self.encode_special(writer) + } + + fn encode_full(&self, writer: &mut W) -> io::Result<()> { + // Key section + write!(writer, "\x1B[{}", self.key)?; + // Write our alternates + if let Some(shifted) = self.alternates[0] { + write!(writer, ":{shifted}")?; + } + if let Some(base) = self.alternates[1] { + if self.alternates[0].is_none() { + write!(writer, "::{base}")?; + } else { + write!(writer, ":{base}")?; + } + } + + // Mods and events section + let mods = self.mods.seq_int(); + let mut emit_prior = false; + if self.event != KittyEvent::None && self.event != KittyEvent::Press { + write!(writer, ";{}:{}", mods, self.event as u8)?; + emit_prior = true; + } else if mods > 1 { + write!(writer, ";{mods}")?; + emit_prior = true; + } + + // Text section + if !self.text.is_empty() { + let mut count = 0; + for c in self.text.chars() { + let cp = u32::from(c); + // If the codepoint is non-printable ASCII character, skip. + if is_control(cp) { + continue; + } + + // We need to add our ";". We need to add two if we didn't emit + // the modifier section. We only do this initially. + if count == 0 { + if !emit_prior { + writer.write_all(b";")?; + } + writer.write_all(b";")?; + } else { + writer.write_all(b":")?; + } + + write!(writer, "{cp}")?; + count += 1; + } + } + + writer.write_all(&[self.final_]) + } + + fn encode_special(&self, writer: &mut W) -> io::Result<()> { + let mods = self.mods.seq_int(); + let f = self.final_ as char; + if self.event != KittyEvent::None { + return write!(writer, "\x1B[1;{}:{}{}", mods, self.event as u8, f); + } + + if mods > 1 { + return write!(writer, "\x1B[1;{mods}{f}"); + } + + write!(writer, "\x1B[{f}") + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::key_mods::{ModSide, Mods, Side}; + + #[test] + fn kitty_plain_text() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::KeyA, + mods: Mods { ..Mods::default() }, + utf8: "abcd", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"abcd"); + } + } + + #[test] + fn kitty_repeat_with_just_disambiguate() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::KeyA, + action: key::Action::Repeat, + mods: Mods { ..Mods::default() }, + utf8: "a", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"a"); + } + } + + #[test] + fn kitty_enter_backspace_tab() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Enter, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x0d"); + } + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Backspace, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x7f"); + } + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Tab, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x09"); + } + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::Enter, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b""); + } + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::Backspace, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b""); + } + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::Tab, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b""); + } + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::Enter, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_all: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[13;1:3u"); + } + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::Backspace, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_all: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[127;1:3u"); + } + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::Tab, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_all: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[9;1:3u"); + } + } + + #[test] + fn kitty_shift_backspace_emits_csi_u() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Backspace, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[127;2u"); + } + } + + #[test] + fn kitty_shift_enter_emits_csi_u() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Enter, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[13;2u"); + } + } + + #[test] + fn kitty_shift_tab_emits_csi_u() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Tab, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[9;2u"); + } + } + + #[test] + fn kitty_enter_with_all_flags() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Enter, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_alternates: true, + report_all: true, + report_associated: true, + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w[1..], b"[13u"); + } + } + + #[test] + fn kitty_ctrl_with_all_flags() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::ControlLeft, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_alternates: true, + report_all: true, + report_associated: true, + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w[1..], b"[57442;5u"); + } + } + + #[test] + fn kitty_ctrl_release_with_ctrl_mod_set() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::ControlLeft, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_alternates: true, + report_all: true, + report_associated: true, + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w[1..], b"[57442;5:3u"); + } + } + + #[test] + fn kitty_delete() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Delete, + mods: Mods { ..Mods::default() }, + utf8: "\u{7f}", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[3~"); + } + } + + #[test] + fn kitty_composing_with_no_modifier() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::KeyA, + mods: Mods { + shift: true, + ..Mods::default() + }, + composing: true, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b""); + } + } + + #[test] + fn kitty_composing_with_modifier() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::ShiftLeft, + mods: Mods { + shift: true, + ..Mods::default() + }, + composing: true, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[57441;2u"); + } + } + + #[test] + fn kitty_composed_text_with_report_all() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Unidentified, + mods: Mods { ..Mods::default() }, + utf8: "\u{fb}", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_alternates: true, + report_all: true, + report_associated: true, + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\xc3\xbb"); + } + } + + #[test] + fn kitty_shift_a_on_us_keyboard() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::KeyA, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "A", + unshifted_codepoint: 97, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_alternates: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[97:65;2u"); + } + } + + #[test] + fn kitty_matching_unshifted_codepoint() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::KeyA, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "A", + unshifted_codepoint: 65, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_alternates: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[65::97;2u"); + } + } + + #[test] + fn kitty_report_alternates_with_caps() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::KeyJ, + mods: Mods { + caps_lock: true, + ..Mods::default() + }, + utf8: "J", + unshifted_codepoint: 106, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[106;65;74u"); + } + } + + #[test] + fn kitty_report_alternates_colon_shift() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Semicolon, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: ":", + unshifted_codepoint: u32::from(';'), + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[59:58;2;58u"); + } + } + + #[test] + fn kitty_report_alternates_with_ru_layout() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Semicolon, + mods: Mods { ..Mods::default() }, + utf8: "\u{447}", + unshifted_codepoint: 1095, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[1095::59;;1095u"); + } + } + + #[test] + fn kitty_report_alternates_with_ru_layout_shifted() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Semicolon, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "\u{427}", + unshifted_codepoint: 1095, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[1095:1063:59;2;1063u"); + } + } + + #[test] + fn kitty_report_alternates_with_ru_layout_caps_lock() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Semicolon, + mods: Mods { + caps_lock: true, + ..Mods::default() + }, + utf8: "\u{427}", + unshifted_codepoint: 1095, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[1095::59;65;1063u"); + } + } + + #[test] + fn kitty_report_alternates_with_hu_layout_release() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::BracketLeft, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "", + unshifted_codepoint: 337, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + report_events: true, + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w[1..], b"[337::91;5:3u"); + } + } + + #[test] + fn kitty_up_arrow_with_utf8() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::ArrowUp, + mods: Mods { ..Mods::default() }, + utf8: "\u{1e}", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[A"); + } + } + + #[test] + fn kitty_shift_tab() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Tab, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_alternates: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[9;2u"); + } + } + + #[test] + fn kitty_left_shift() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::ShiftLeft, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_alternates: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b""); + } + } + + #[test] + fn kitty_left_shift_with_report_all() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::ShiftLeft, + mods: Mods { ..Mods::default() }, + utf8: "", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[57441u"); + } + } + + #[test] + fn kitty_report_associated_with_modifiers() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::KeyJ, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "j", + unshifted_codepoint: 106, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[106;5u"); + } + } + + #[test] + fn kitty_report_associated() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::KeyJ, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "J", + unshifted_codepoint: 106, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[106:74;2;74u"); + } + } + + #[test] + fn kitty_report_associated_on_release() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + action: key::Action::Release, + key: Key::KeyJ, + mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "J", + unshifted_codepoint: 106, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + report_events: true, + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w[1..], b"[106:74;2:3u"); + } + } + + #[test] + fn kitty_alternates_omit_control_characters() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Delete, + mods: Mods { ..Mods::default() }, + utf8: "\u{7f}", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_alternates: true, + report_all: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[3~"); + } + } + + #[test] + fn kitty_enter_with_utf8_dead_key_state() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Enter, + utf8: "A", + unshifted_codepoint: 0x0D, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_alternates: true, + report_all: true, + ..KittyFlags::DISABLED + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"A"); + } + } + + #[test] + fn kitty_keypad_number() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Numpad1, + mods: Mods { ..Mods::default() }, + utf8: "1", + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_alternates: true, + report_all: true, + report_associated: true, + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w[1..], b"[57400;;49u"); + } + } + + #[test] + fn kitty_backspace_with_utf8_dead_key_state() { + { + let mut w = Vec::new(); + kitty( + &mut w, + &KeyEvent { + key: Key::Backspace, + utf8: "A", + unshifted_codepoint: 0x0D, + ..KeyEvent::default() + }, + &Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_events: true, + report_alternates: true, + report_all: true, + report_associated: true, + }, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b""); + } + } + + #[test] + fn legacy_backspace_with_utf8_dead_key_state() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Backspace, + utf8: "A", + unshifted_codepoint: 0x0D, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b""); + } + } + + #[test] + fn legacy_enter_with_utf8_dead_key_state() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Enter, + utf8: "A", + unshifted_codepoint: 0x0D, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"A"); + } + } + + #[test] + fn legacy_esc_with_utf8_dead_key_state() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Escape, + utf8: "A", + unshifted_codepoint: 0x0D, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"A"); + } + } + + #[test] + fn legacy_ctrl_shift_minus_underscore_on_us() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Minus, + mods: Mods { + ctrl: true, + shift: true, + ..Mods::default() + }, + utf8: "_", + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1f"); + } + } + + #[test] + fn legacy_ctrl_alt_c() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyC, + mods: Mods { + ctrl: true, + alt: true, + ..Mods::default() + }, + utf8: "c", + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b\x03"); + } + } + + #[test] + fn legacy_alt_c() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyC, + utf8: "c", + mods: Mods { + alt: true, + ..Mods::default() + }, + ..KeyEvent::default() + }, + &Options { + alt_esc_prefix: true, + macos_option_as_alt: OptionAsAlt::True, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1bc"); + } + } + + #[test] + fn legacy_alt_e_only_unshifted() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyE, + unshifted_codepoint: u32::from('e'), + mods: Mods { + alt: true, + ..Mods::default() + }, + ..KeyEvent::default() + }, + &Options { + alt_esc_prefix: true, + macos_option_as_alt: OptionAsAlt::True, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1be"); + } + } + + #[test] + fn legacy_alt() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyF, + utf8: "\u{444}", + mods: Mods { + alt: true, + ..Mods::default() + }, + ..KeyEvent::default() + }, + &Options { + alt_esc_prefix: true, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\xd1\x84"); + } + } + + #[test] + fn legacy_ctrl_c() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyC, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "c", + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x03"); + } + } + + #[test] + fn legacy_ctrl_space() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Space, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: " ", + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x00"); + } + } + + #[test] + fn legacy_ctrl_shift_backspace() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Backspace, + mods: Mods { + ctrl: true, + shift: true, + ..Mods::default() + }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x08"); + } + } + + #[test] + fn legacy_ctrl_shift_char_with_modify_other_state_2() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyH, + mods: Mods { + ctrl: true, + shift: true, + ..Mods::default() + }, + utf8: "H", + ..KeyEvent::default() + }, + &Options { + modify_other_keys_state_2: true, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[27;6;72~"); + } + } + + #[test] + fn legacy_ctrl_shift_char_with_modify_other_state_2_and_consumed_mods() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyH, + mods: Mods { + ctrl: true, + shift: true, + ..Mods::default() + }, + consumed_mods: Mods { + shift: true, + ..Mods::default() + }, + utf8: "H", + ..KeyEvent::default() + }, + &Options { + modify_other_keys_state_2: true, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[27;6;72~"); + } + } + + #[test] + fn legacy_alt_digit_with_modify_other_state_2() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Digit8, + mods: Mods { + alt: true, + ..Mods::default() + }, + consumed_mods: Mods { ..Mods::default() }, + utf8: "8", + ..KeyEvent::default() + }, + &Options { + modify_other_keys_state_2: true, + macos_option_as_alt: OptionAsAlt::True, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[27;3;56~"); + } + } + + #[test] + fn legacy_fixterm_awkward_letters() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyI, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "i", + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[105;5u"); + } + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyM, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "m", + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[109;5u"); + } + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::BracketLeft, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "[", + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[91;5u"); + } + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Digit2, + mods: Mods { + ctrl: true, + shift: true, + ..Mods::default() + }, + utf8: "@", + unshifted_codepoint: u32::from('2'), + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[64;5u"); + } + } + + #[test] + fn legacy_ctrl_shift_letter_ascii() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::KeyM, + mods: Mods { + ctrl: true, + shift: true, + ..Mods::default() + }, + utf8: "M", + unshifted_codepoint: u32::from('m'), + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[109;6u"); + } + } + + #[test] + fn legacy_shift_function_key_should_use_all_mods() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::ArrowUp, + mods: Mods { + shift: true, + ..Mods::default() + }, + consumed_mods: Mods { + shift: true, + ..Mods::default() + }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[1;2A"); + } + } + + #[test] + fn legacy_keypad_enter() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::NumpadEnter, + mods: Mods { ..Mods::default() }, + consumed_mods: Mods { ..Mods::default() }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x0d"); + } + } + + #[test] + fn legacy_keypad_1() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Numpad1, + mods: Mods { ..Mods::default() }, + consumed_mods: Mods { ..Mods::default() }, + utf8: "1", + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"1"); + } + } + + #[test] + fn legacy_keypad_1_with_application_keypad() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Numpad1, + mods: Mods { ..Mods::default() }, + consumed_mods: Mods { ..Mods::default() }, + utf8: "1", + ..KeyEvent::default() + }, + &Options { + keypad_key_application: true, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1bOq"); + } + } + + #[test] + fn legacy_keypad_1_with_application_keypad_and_numlock() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Numpad1, + mods: Mods { + num_lock: true, + ..Mods::default() + }, + consumed_mods: Mods { ..Mods::default() }, + utf8: "1", + ..KeyEvent::default() + }, + &Options { + keypad_key_application: true, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1bOq"); + } + } + + #[test] + fn legacy_keypad_1_with_application_keypad_and_numlock_ignore() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Numpad1, + mods: Mods { + num_lock: false, + ..Mods::default() + }, + consumed_mods: Mods { ..Mods::default() }, + utf8: "1", + ..KeyEvent::default() + }, + &Options { + keypad_key_application: true, + ignore_keypad_with_numlock: true, + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"1"); + } + } + + #[test] + fn legacy_f1() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::F1, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + consumed_mods: Mods { ..Mods::default() }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[1;5P"); + } + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::F2, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + consumed_mods: Mods { ..Mods::default() }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[1;5Q"); + } + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::F3, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + consumed_mods: Mods { ..Mods::default() }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[13;5~"); + } + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::F4, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + consumed_mods: Mods { ..Mods::default() }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[1;5S"); + } + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::F5, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + consumed_mods: Mods { ..Mods::default() }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[15;5~"); + } + } + + #[test] + fn legacy_left_shift_tab() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Tab, + mods: Mods { + shift: true, + sides: Side { + shift: ModSide::Left, + ..Side::default() + }, + ..Mods::default() + }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[Z"); + } + } + + #[test] + fn legacy_right_shift_tab() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Tab, + mods: Mods { + shift: true, + sides: Side { + shift: ModSide::Right, + ..Side::default() + }, + ..Mods::default() + }, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x1b[Z"); + } + } + + #[test] + fn legacy_hu_layout_ctrl_sends_proper_codepoint() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::BracketLeft, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "\u{151}", + unshifted_codepoint: 337, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w[1..], b"[337;5u"); + } + } + + #[test] + fn legacy_backspace_with_del_utf8() { + { + let mut w = Vec::new(); + legacy( + &mut w, + &KeyEvent { + key: Key::Backspace, + utf8: "\u{7f}", + unshifted_codepoint: 0x08, + ..KeyEvent::default() + }, + &Options { + ..Options::default() + }, + ) + .unwrap(); + assert_eq!(&w, b"\x7f"); + } + } + fn enc(seq: KittySequence) -> Vec { + let mut w = Vec::new(); + seq.encode(&mut w).unwrap(); + w + } + + fn seq(key: u32, final_: u8) -> KittySequence<'static> { + KittySequence { + key, + final_, + mods: KittyMods::default(), + event: KittyEvent::None, + alternates: [None, None], + text: "", + } + } + + fn ctrl() -> Mods { + Mods { + ctrl: true, + ..Mods::default() + } + } + + #[test] + fn csi_u_mods_modifier_sequence_values() { + let c = |shift, alt, ctrl| CsiUMods { shift, alt, ctrl }.seq_int(); + assert_eq!(1, c(false, false, false)); + assert_eq!(2, c(true, false, false)); + assert_eq!(3, c(false, true, false)); + assert_eq!(5, c(false, false, true)); + assert_eq!(4, c(true, true, false)); + assert_eq!(6, c(true, false, true)); + assert_eq!(7, c(false, true, true)); + assert_eq!(8, c(true, true, true)); + } + + #[test] + fn kitty_mods_modifier_sequence_values() { + let c = |shift, alt, ctrl| { + KittyMods { + shift, + alt, + ctrl, + ..KittyMods::default() + } + .seq_int() + }; + assert_eq!(1, c(false, false, false)); + assert_eq!(2, c(true, false, false)); + assert_eq!(3, c(false, true, false)); + assert_eq!(5, c(false, false, true)); + assert_eq!(4, c(true, true, false)); + assert_eq!(6, c(true, false, true)); + assert_eq!(7, c(false, true, true)); + assert_eq!(8, c(true, true, true)); + } + + #[test] + fn kitty_sequence_backspace() { + assert_eq!(enc(seq(127, b'u')), b"\x1B[127u"); + assert_eq!( + enc(KittySequence { + event: KittyEvent::Release, + ..seq(127, b'u') + }), + b"\x1B[127;1:3u" + ); + assert_eq!( + enc(KittySequence { + mods: KittyMods { + shift: true, + ..KittyMods::default() + }, + ..seq(127, b'u') + }), + b"\x1B[127;2u" + ); + } + + #[test] + fn kitty_sequence_text() { + assert_eq!( + enc(KittySequence { + text: "A", + ..seq(127, b'u') + }), + b"\x1B[127;;65u" + ); + assert_eq!( + enc(KittySequence { + event: KittyEvent::Release, + text: "A", + ..seq(127, b'u') + }), + b"\x1B[127;1:3;65u" + ); + assert_eq!( + enc(KittySequence { + mods: KittyMods { + shift: true, + ..KittyMods::default() + }, + text: "A", + ..seq(127, b'u') + }), + b"\x1B[127;2;65u" + ); + } + + #[test] + fn kitty_sequence_text_with_control_characters() { + assert_eq!( + enc(KittySequence { + text: "\n", + ..seq(127, b'u') + }), + b"\x1b[127u" + ); + assert_eq!( + enc(KittySequence { + text: "A\n", + ..seq(127, b'u') + }), + b"\x1b[127;;65u" + ); + } + + #[test] + fn kitty_sequence_special_no_mods() { + assert_eq!(enc(seq(1, b'A')), b"\x1B[A"); + } + + #[test] + fn kitty_sequence_special_mods_only() { + assert_eq!( + enc(KittySequence { + mods: KittyMods { + shift: true, + ..KittyMods::default() + }, + ..seq(1, b'A') + }), + b"\x1B[1;2A" + ); + } + + #[test] + fn kitty_sequence_special_mods_and_event() { + assert_eq!( + enc(KittySequence { + event: KittyEvent::Release, + mods: KittyMods { + shift: true, + ..KittyMods::default() + }, + ..seq(1, b'A') + }), + b"\x1B[1;2:3A" + ); + } + + fn right_ctrl() -> Mods { + Mods { + ctrl: true, + sides: Side { + ctrl: ModSide::Right, + ..Side::default() + }, + ..Mods::default() + } + } + + #[test] + fn ctrlseq_normal_ctrl_c() { + assert_eq!( + ctrl_seq(Key::Unidentified, "c", 'c' as u32, ctrl()), + Some(0x03) + ); + } + + #[test] + fn ctrlseq_normal_ctrl_c_right_control() { + assert_eq!( + ctrl_seq(Key::Unidentified, "c", 'c' as u32, right_ctrl()), + Some(0x03) + ); + } + + #[test] + fn ctrlseq_alt_should_be_allowed() { + let mods = Mods { + alt: true, + ctrl: true, + ..Mods::default() + }; + assert_eq!( + ctrl_seq(Key::Unidentified, "c", 'c' as u32, mods), + Some(0x03) + ); + } + + #[test] + fn ctrlseq_no_ctrl_does_nothing() { + assert_eq!( + ctrl_seq(Key::Unidentified, "c", 'c' as u32, Mods::default()), + None + ); + } + + #[test] + fn ctrlseq_shifted_non_character() { + let mods = Mods { + ctrl: true, + shift: true, + ..Mods::default() + }; + assert_eq!( + ctrl_seq(Key::Unidentified, "_", '-' as u32, mods), + Some(0x1F) + ); + } + + #[test] + fn ctrlseq_caps_ascii_letter() { + let mods = Mods { + ctrl: true, + caps_lock: true, + ..Mods::default() + }; + assert_eq!( + ctrl_seq(Key::Unidentified, "C", 'c' as u32, mods), + Some(0x03) + ); + } + + #[test] + fn ctrlseq_shift_does_not_generate_ctrl_seq() { + let shift = Mods { + shift: true, + ..Mods::default() + }; + assert_eq!(ctrl_seq(Key::Unidentified, "C", 'c' as u32, shift), None); + let both = Mods { + shift: true, + ctrl: true, + ..Mods::default() + }; + assert_eq!(ctrl_seq(Key::Unidentified, "C", 'c' as u32, both), None); + } + + #[test] + fn ctrlseq_russian_ctrl_c() { + assert_eq!(ctrl_seq(Key::KeyC, "с", 0x0441, ctrl()), Some(0x03)); + } + + #[test] + fn ctrlseq_russian_shifted_ctrl_c() { + let mods = Mods { + ctrl: true, + shift: true, + ..Mods::default() + }; + assert_eq!(ctrl_seq(Key::KeyC, "с", 0x0441, mods), None); + } + + #[test] + fn ctrlseq_russian_alt_ctrl_c() { + let mods = Mods { + ctrl: true, + alt: true, + ..Mods::default() + }; + assert_eq!(ctrl_seq(Key::KeyC, "с", 0x0441, mods), Some(0x03)); + } + + #[test] + fn ctrlseq_right_ctrl_c() { + assert_eq!( + ctrl_seq(Key::KeyC, "с", 'c' as u32, right_ctrl()), + Some(0x03) + ); + } +} diff --git a/crates/ghostty-input/src/key_mods.rs b/crates/ghostty-input/src/key_mods.rs new file mode 100644 index 0000000..e024e80 --- /dev/null +++ b/crates/ghostty-input/src/key_mods.rs @@ -0,0 +1,869 @@ +//! Key modifiers (ZIG/src/input/key_mods.zig). + +use crate::config::OptionAsAlt; + +/// Aliases for modifier names. +pub const ALIAS: &[(&str, Mod)] = &[ + ("cmd", Mod::Super), + ("command", Mod::Super), + ("opt", Mod::Alt), + ("option", Mod::Alt), + ("control", Mod::Ctrl), +]; + +/// Single modifier +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mod { + Shift, + Ctrl, + Alt, + Super, +} + +impl Mod { + fn from_name(s: &str) -> Option { + match s { + "shift" => Some(Mod::Shift), + "ctrl" => Some(Mod::Ctrl), + "alt" => Some(Mod::Alt), + "super" => Some(Mod::Super), + _ => None, + } + } +} + +/// Which side of the keyboard a modifier is on (`Mod.Side`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[repr(u8)] +pub enum ModSide { + #[default] + Left = 0, + Right = 1, +} + +impl ModSide { + fn from_name(s: &str) -> Option { + match s { + "left" => Some(ModSide::Left), + "right" => Some(ModSide::Right), + _ => None, + } + } +} + +/// The standard modifier keys only. Does not include the lock keys, +/// only standard bindable keys. (`Mods.Keys`, a packed u4) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Keys { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, + pub super_: bool, +} + +impl Keys { + pub fn int(self) -> u8 { + u8::from(self.shift) + | u8::from(self.ctrl) << 1 + | u8::from(self.alt) << 2 + | u8::from(self.super_) << 3 + } + + pub fn from_int(v: u8) -> Keys { + Keys { + shift: v & 1 != 0, + ctrl: v & 2 != 0, + alt: v & 4 != 0, + super_: v & 8 != 0, + } + } +} + +/// Tracks the side that is active for any given modifier. Note +/// that this doesn't confirm a modifier is pressed; you must check +/// the bool for that in addition to this. (`Mods.Side`, a packed u4) +/// +/// Not all platforms support this, check apprt for more info. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Side { + pub shift: ModSide, + pub ctrl: ModSide, + pub alt: ModSide, + pub super_: ModSide, +} + +impl Side { + pub fn int(self) -> u8 { + self.shift as u8 | (self.ctrl as u8) << 1 | (self.alt as u8) << 2 | (self.super_ as u8) << 3 + } + + pub fn from_int(v: u8) -> Side { + let f = |b: u8| { + if v & b != 0 { + ModSide::Right + } else { + ModSide::Left + } + }; + Side { + shift: f(1), + ctrl: f(2), + alt: f(4), + super_: f(8), + } + } +} + +/// A bitmask for all key modifiers. +/// +/// NOTE: Zig's field `super` is named `super_` here (`super` is a Rust +/// keyword). The packed integer layout (u16) is available via `int()`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Mods { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, + pub super_: bool, + pub caps_lock: bool, + pub num_lock: bool, + pub sides: Side, +} + +impl Mods { + /// The mask that has all the side bits set. + pub const SIDE_MASK: Mods = Mods { + shift: false, + ctrl: false, + alt: false, + super_: false, + caps_lock: false, + num_lock: false, + sides: Side { + shift: ModSide::Right, + ctrl: ModSide::Right, + alt: ModSide::Right, + super_: ModSide::Right, + }, + }; + + /// Integer value of this struct. + pub fn int(self) -> u16 { + u16::from(self.keys().int()) + | u16::from(self.caps_lock) << 4 + | u16::from(self.num_lock) << 5 + | u16::from(self.sides.int()) << 6 + } + + pub fn from_int(v: u16) -> Mods { + Mods { + shift: v & 1 != 0, + ctrl: v & 2 != 0, + alt: v & 4 != 0, + super_: v & 8 != 0, + caps_lock: v & 16 != 0, + num_lock: v & 32 != 0, + sides: Side::from_int(((v >> 6) & 0xF) as u8), + } + } + + /// Returns true if no modifiers are set. + pub fn empty(self) -> bool { + self.int() == 0 + } + + /// Returns true if two mods are equal. + pub fn equal(self, other: Mods) -> bool { + self.int() == other.int() + } + + /// Returns only the keys. + pub fn keys(self) -> Keys { + Keys { + shift: self.shift, + ctrl: self.ctrl, + alt: self.alt, + super_: self.super_, + } + } + + /// Return mods that are only relevant for bindings. + pub fn binding(self) -> Mods { + Mods { + shift: self.shift, + ctrl: self.ctrl, + alt: self.alt, + super_: self.super_, + ..Mods::default() + } + } + + /// Perform `self &~ other` to remove the other mods from self. + pub fn unset(self, other: Mods) -> Mods { + Mods::from_int(self.int() & !other.int()) + } + + /// Returns the mods without locks set. + pub fn without_locks(self) -> Mods { + let mut copy = self; + copy.caps_lock = false; + copy.num_lock = false; + copy + } + + /// Return the mods to use for key translation. This handles settings + /// like macos-option-as-alt. The translation mods should be used for + /// translation but never sent back in for the key callback. + /// + /// Only macOS changes anything here; this port targets Linux so the + /// mods are returned unchanged. + pub fn translation(self, _option_as_alt: OptionAsAlt) -> Mods { + self + } + + /// Checks to see if super is on (MacOS) or ctrl. + pub fn ctrl_or_super(self) -> bool { + // Non-darwin target. + self.ctrl + } +} + +/// Modifier remapping. See `key-remap` in Config.zig for detailed docs. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RemapSet { + /// Available mappings (insertion ordered, like an array hash map). + pub map: Vec<(Mods, Mods)>, + + /// The mask of remapped modifiers that can be used to quickly + /// check if some input mods need remapping. + pub mask: Mask, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ParseError { + #[error("missing assignment")] + MissingAssignment, + #[error("invalid modifier")] + InvalidMod, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ParseCliError { + #[error("invalid value")] + InvalidValue, +} + +impl RemapSet { + pub const EMPTY: RemapSet = RemapSet { + map: Vec::new(), + mask: Mask { + keys: Keys { + shift: false, + ctrl: false, + alt: false, + super_: false, + }, + left_sides: Side { + shift: ModSide::Left, + ctrl: ModSide::Left, + alt: ModSide::Left, + super_: ModSide::Left, + }, + right_sides: Side { + shift: ModSide::Left, + ctrl: ModSide::Left, + alt: ModSide::Left, + super_: ModSide::Left, + }, + }, + }; + + fn put(&mut self, from: Mods, to: Mods) { + if let Some(e) = self.map.iter_mut().find(|e| e.0 == from) { + e.1 = to; + } else { + self.map.push((from, to)); + } + } + + /// Parse from CLI input. Required by Config. + pub fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ParseCliError> { + let value = input.unwrap_or(""); + + // Empty value resets the set + if value.is_empty() { + self.map.clear(); + self.mask = Mask::default(); + return Ok(()); + } + + self.parse(value).map_err(|_| ParseCliError::InvalidValue) + } + + /// Parse a modifier remap and add it to the set. + pub fn parse(&mut self, input: &str) -> Result<(), ParseError> { + // Find the assignment point ('=') + let eql_idx = input.find('=').ok_or(ParseError::MissingAssignment)?; + + // The to side defaults to "left" if no explicit side is given. + // This is because this is the default unsided value provided by + // the apprts in the current Mods layout. + let to = { + let raw = parse_mod(&input[eql_idx + 1..])?; + init_mods(raw.0, raw.1.unwrap_or(ModSide::Left)) + }; + + // The from side, if sided, is easy and we put it directly into + // the map. + let from_raw = parse_mod(&input[..eql_idx])?; + if let Some(from_side) = from_raw.1 { + let from = init_mods(from_raw.0, from_side); + self.put(from, to); + self.mask.update(from); + return Ok(()); + } + + // We need to do some combinatorial explosion here for unsided + // from in order to assign all possible sides. + let from_left = init_mods(from_raw.0, ModSide::Left); + let from_right = init_mods(from_raw.0, ModSide::Right); + self.put(from_left, to); + self.put(from_right, to); + self.mask.update(from_left); + self.mask.update(from_right); + Ok(()) + } + + /// Must be called prior to any remappings so that the mapping + /// is sorted properly. Otherwise, you will get invalid results. + pub fn finalize(&mut self) { + // Mods with any right sides prioritize. This mirrors the Zig + // comparator (which ignores `b`) driven by an insertion sort. + let side_mask = Mods::SIDE_MASK.int(); + let less = |a: &Mods| a.int() & side_mask != 0; + for i in 1..self.map.len() { + let mut j = i; + while j > 0 && less(&self.map[j].0) { + self.map.swap(j, j - 1); + j -= 1; + } + } + } + + /// Compare if two RemapSets are equal. Required by Config. + pub fn equal(&self, other: &RemapSet) -> bool { + if self.map.len() != other.map.len() { + return false; + } + self.map.iter().all(|(k, v)| { + other + .map + .iter() + .find(|(ok, _)| ok == k) + .is_some_and(|(_, ov)| v.equal(*ov)) + }) + } + + /// Used by Formatter. Required by Config. Returns the values of each + /// `key-remap = ...` line to print. An empty vec means the formatter + /// should emit a single empty entry (`key-remap = `). + pub fn format_entries(&self) -> Vec { + self.map + .iter() + .map(|(from, to)| { + let mut s = String::new(); + format_mod(&mut s, *from); + s.push('='); + format_mod(&mut s, *to); + s + }) + .collect() + } + + /// Returns true if the given mods need remapping. + pub fn is_remapped(&self, mods: Mods) -> bool { + self.mask.matches(mods) + } + + /// Apply a remap to the given mods. + pub fn apply(&self, mods: Mods) -> Mods { + if !self.is_remapped(mods) { + return mods; + } + + let mods_binding = mods.keys().int(); + let mods_sides = mods.sides.int(); + + for (from, to) in &self.map { + let from_binding = from.keys().int(); + if mods_binding & from_binding != from_binding { + continue; + } + let from_sides = from.sides.int(); + if (mods_sides ^ from_sides) & from_binding != 0 { + continue; + } + + let mut mods_int = mods.int(); + mods_int &= !from.int(); + mods_int |= to.int(); + return Mods::from_int(mods_int); + } + + unreachable!("is_remapped true but no mapping matched") + } +} + +fn format_mod(out: &mut String, mods: Mods) { + // Check which mod is set and format it with optional side prefix + let list = [ + ("shift", mods.shift, mods.sides.shift), + ("ctrl", mods.ctrl, mods.sides.ctrl), + ("alt", mods.alt, mods.sides.alt), + ("super", mods.super_, mods.sides.super_), + ]; + for (name, set, side) in list { + if set { + if side == ModSide::Right { + out.push_str("right_"); + } else { + // Only write left_ if we need to distinguish + // For now, always write left_ if it's a sided mapping + out.push_str("left_"); + } + out.push_str(name); + return; + } + } +} + +/// Parses a single mode in a single remapping string. E.g. +/// `ctrl` or `left_shift`. +fn parse_mod(input: &str) -> Result<(Mod, Option), ParseError> { + let (side_str, mod_str) = match input.find('_') { + Some(idx) => (&input[..idx], &input[idx + 1..]), + None => ("", input), + }; + + let m = match Mod::from_name(mod_str) { + Some(m) => m, + None => ALIAS + .iter() + .find(|p| p.0 == mod_str) + .map(|p| p.1) + .ok_or(ParseError::InvalidMod)?, + }; + + let side = if side_str.is_empty() { + None + } else { + Some(ModSide::from_name(side_str).ok_or(ParseError::InvalidMod)?) + }; + Ok((m, side)) +} + +fn init_mods(m: Mod, side: ModSide) -> Mods { + let mut mods = Mods::default(); + match m { + Mod::Shift => { + mods.shift = true; + mods.sides.shift = side; + } + Mod::Ctrl => { + mods.ctrl = true; + mods.sides.ctrl = side; + } + Mod::Alt => { + mods.alt = true; + mods.sides.alt = side; + } + Mod::Super => { + mods.super_ = true; + mods.sides.super_ = side; + } + } + mods +} + +/// Tracks which modifier keys and sides have remappings registered. +/// Used as a fast pre-check before doing expensive map lookups. +/// +/// The mask uses separate tracking for left and right sides because +/// remappings can be side-specific (e.g., only remap left_ctrl). +/// +/// Note: `left_sides` uses inverted logic where 1 means "left is remapped" +/// even though `ModSide::Left = 0`. This allows efficient bitwise matching +/// since we can AND directly with the side bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Mask { + /// Which modifier keys (shift/ctrl/alt/super) have any remapping. + pub keys: Keys, + /// Which modifiers have left-side remappings (inverted: 1 = left remapped). + pub left_sides: Side, + /// Which modifiers have right-side remappings (1 = right remapped). + pub right_sides: Side, +} + +impl Mask { + /// Adds a modifier to the mask, marking it as having a remapping. + pub fn update(&mut self, mods: Mods) { + let keys_int = mods.keys().int(); + + // OR the new keys into our existing keys mask. + self.keys = Keys::from_int(self.keys.int() | keys_int); + + // Both Keys and Side are u4 with matching bit positions. + // This lets us use keys_int to select which side bits to update. + let sides = mods.sides.int(); + let left_int = self.left_sides.int(); + let right_int = self.right_sides.int(); + + // Update left_sides: set bit if this key is active AND side is left. + // Since Left=0, we invert sides so left becomes 1. + self.left_sides = Side::from_int(left_int | (keys_int & !sides)); + + // Update right_sides: set bit if this key is active AND side is right. + self.right_sides = Side::from_int(right_int | (keys_int & sides)); + } + + /// Returns true if the given mods match any remapping in this mask. + /// This is a fast check to avoid expensive map lookups when no + /// remapping could possibly apply. + /// + /// Checks both that the modifier key is remapped AND that the + /// specific side (left/right) being pressed has a remapping. + pub fn matches(&self, mods: Mods) -> bool { + // Find which pressed keys have remappings registered. + let active = mods.keys().int() & self.keys.int(); + if active == 0 { + return false; + } + + // Check if the pressed side matches a remapped side. + let sides = mods.sides.int(); + let left_int = self.left_sides.int(); + let right_int = self.right_sides.int(); + let side_match = (!sides & left_int) | (sides & right_int); + + // Final check: is any active (pressed + remapped) key also side-matched? + (active & side_match) != 0 + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn m(f: impl FnOnce(&mut Mods)) -> Mods { + let mut x = Mods::default(); + f(&mut x); + x + } + + // For our own understanding + #[test] + fn mods_layout() { + assert_eq!(Mods::default().int(), 0); + assert_eq!( + Mods { + shift: true, + ..Default::default() + } + .int(), + 1 + ); + assert_eq!(Mods::from_int(Mods::SIDE_MASK.int()), Mods::SIDE_MASK); + } + + #[test] + fn translation_macos_option_as_alt() { + // Darwin only upstream; on Linux it is the identity. + let mods = Mods { + alt: true, + ..Default::default() + }; + assert_eq!(mods.translation(OptionAsAlt::True), mods); + } + + fn left(k: &str) -> Mods { + match k { + "ctrl" => m(|x| x.ctrl = true), + "alt" => m(|x| x.alt = true), + "shift" => m(|x| x.shift = true), + _ => m(|x| x.super_ = true), + } + } + + #[test] + fn remap_set_unsided_remap_creates_both_left_and_right_mappings() { + let mut set = RemapSet::default(); + set.parse("ctrl=super").unwrap(); + set.finalize(); + let want = Mods { + super_: true, + sides: Side { + super_: ModSide::Left, + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + want, + set.apply(Mods { + ctrl: true, + sides: Side { + ctrl: ModSide::Left, + ..Default::default() + }, + ..Default::default() + }) + ); + assert_eq!( + want, + set.apply(Mods { + ctrl: true, + sides: Side { + ctrl: ModSide::Right, + ..Default::default() + }, + ..Default::default() + }) + ); + } + + fn sided(name: &str, side: ModSide) -> Mods { + init_mods( + match name { + "shift" => Mod::Shift, + "ctrl" => Mod::Ctrl, + "alt" => Mod::Alt, + _ => Mod::Super, + }, + side, + ) + } + + #[test] + fn remap_set_sided_from_only_maps_that_side() { + let mut set = RemapSet::default(); + set.parse("left_alt=ctrl").unwrap(); + set.finalize(); + assert_eq!( + sided("ctrl", ModSide::Left), + set.apply(sided("alt", ModSide::Left)) + ); + let right_alt = sided("alt", ModSide::Right); + assert_eq!(right_alt, set.apply(right_alt)); + } + + #[test] + fn remap_set_sided_to() { + let mut set = RemapSet::default(); + set.parse("ctrl=right_super").unwrap(); + set.finalize(); + assert_eq!( + sided("super", ModSide::Right), + set.apply(sided("ctrl", ModSide::Left)) + ); + } + + #[test] + fn remap_set_both_sides_specified() { + let mut set = RemapSet::default(); + set.parse("left_shift=right_ctrl").unwrap(); + set.finalize(); + assert_eq!( + sided("ctrl", ModSide::Right), + set.apply(sided("shift", ModSide::Left)) + ); + } + + #[test] + fn remap_set_multiple_parses_accumulate() { + let mut set = RemapSet::default(); + set.parse("left_ctrl=super").unwrap(); + set.parse("left_alt=ctrl").unwrap(); + set.finalize(); + assert_eq!( + sided("super", ModSide::Left), + set.apply(sided("ctrl", ModSide::Left)) + ); + assert_eq!( + sided("ctrl", ModSide::Left), + set.apply(sided("alt", ModSide::Left)) + ); + } + + #[test] + fn remap_set_error_on_missing_assignment() { + let mut set = RemapSet::default(); + assert_eq!(set.parse("ctrl"), Err(ParseError::MissingAssignment)); + assert_eq!(set.parse(""), Err(ParseError::MissingAssignment)); + } + + #[test] + fn remap_set_error_on_invalid_modifier() { + let mut set = RemapSet::default(); + assert_eq!(set.parse("invalid=ctrl"), Err(ParseError::InvalidMod)); + assert_eq!(set.parse("ctrl=invalid"), Err(ParseError::InvalidMod)); + assert_eq!(set.parse("middle_ctrl=super"), Err(ParseError::InvalidMod)); + } + + #[test] + fn remap_set_is_remapped_checks_mask() { + let mut set = RemapSet::default(); + set.parse("ctrl=super").unwrap(); + set.finalize(); + assert!(set.is_remapped(left("ctrl"))); + assert!(!set.is_remapped(left("alt"))); + assert!(!set.is_remapped(left("shift"))); + } + + #[test] + fn remap_set_clone_creates_independent_copy() { + let mut set = RemapSet::default(); + set.parse("ctrl=super").unwrap(); + set.finalize(); + let cloned = set.clone(); + assert!(set.equal(&cloned)); + assert!(cloned.is_remapped(left("ctrl"))); + } + + #[test] + fn remap_set_equal_compares_correctly() { + let mut set1 = RemapSet::default(); + let mut set2 = RemapSet::default(); + assert!(set1.equal(&set2)); + set1.parse("ctrl=super").unwrap(); + assert!(!set1.equal(&set2)); + set2.parse("ctrl=super").unwrap(); + assert!(set1.equal(&set2)); + set1.parse("alt=shift").unwrap(); + assert!(!set1.equal(&set2)); + } + + #[test] + fn remap_set_parse_cli_basic() { + let mut set = RemapSet::default(); + set.parse_cli(Some("ctrl=super")).unwrap(); + assert_eq!(set.map.len(), 2); + } + + #[test] + fn remap_set_parse_cli_empty_clears() { + let mut set = RemapSet::default(); + set.parse_cli(Some("ctrl=super")).unwrap(); + assert_eq!(set.map.len(), 2); + set.parse_cli(Some("")).unwrap(); + assert_eq!(set.map.len(), 0); + } + + #[test] + fn remap_set_parse_cli_invalid() { + let mut set = RemapSet::default(); + assert_eq!( + set.parse_cli(Some("foo=bar")), + Err(ParseCliError::InvalidValue) + ); + assert_eq!( + set.parse_cli(Some("ctrl")), + Err(ParseCliError::InvalidValue) + ); + } + + #[test] + fn remap_set_parse_aliased_modifiers() { + let mut set = RemapSet::default(); + set.parse("cmd=ctrl").unwrap(); + set.finalize(); + assert_eq!( + sided("ctrl", ModSide::Left), + set.apply(sided("super", ModSide::Left)) + ); + } + + #[test] + fn remap_set_parse_aliased_modifiers_command() { + let mut set = RemapSet::default(); + set.parse("command=alt").unwrap(); + set.finalize(); + assert_eq!( + sided("alt", ModSide::Left), + set.apply(sided("super", ModSide::Left)) + ); + } + + #[test] + fn remap_set_parse_aliased_modifiers_opt_and_option() { + let mut set = RemapSet::default(); + set.parse("opt=super").unwrap(); + set.finalize(); + assert_eq!( + sided("super", ModSide::Left), + set.apply(sided("alt", ModSide::Left)) + ); + + let mut set = RemapSet::default(); + set.parse("option=shift").unwrap(); + set.finalize(); + assert_eq!( + sided("shift", ModSide::Left), + set.apply(sided("alt", ModSide::Left)) + ); + } + + #[test] + fn remap_set_parse_aliased_modifiers_control() { + let mut set = RemapSet::default(); + set.parse("control=super").unwrap(); + set.finalize(); + assert_eq!( + sided("super", ModSide::Left), + set.apply(sided("ctrl", ModSide::Left)) + ); + } + + #[test] + fn remap_set_parse_aliased_modifiers_on_target_side() { + let mut set = RemapSet::default(); + set.parse("alt=cmd").unwrap(); + set.finalize(); + assert_eq!( + sided("super", ModSide::Left), + set.apply(sided("alt", ModSide::Left)) + ); + } + + #[test] + fn remap_set_format_entry_empty() { + // The `key-remap = \n` rendering is done by the config formatter; + // here an empty set yields no entries. + assert_eq!(RemapSet::default().format_entries(), Vec::::new()); + } + + #[test] + fn remap_set_format_entry_single_sided() { + let mut set = RemapSet::default(); + set.parse("left_ctrl=super").unwrap(); + set.finalize(); + assert_eq!(set.format_entries(), vec!["left_ctrl=left_super"]); + } + + #[test] + fn remap_set_format_entry_unsided_creates_two_entries() { + let mut set = RemapSet::default(); + set.parse("ctrl=super").unwrap(); + set.finalize(); + let w = set.format_entries(); + assert!(w.contains(&"left_ctrl=left_super".to_string())); + assert!(w.contains(&"right_ctrl=left_super".to_string())); + } + + #[test] + fn remap_set_format_entry_right_sided() { + let mut set = RemapSet::default(); + set.parse("left_alt=right_ctrl").unwrap(); + set.finalize(); + assert_eq!(set.format_entries(), vec!["left_alt=right_ctrl"]); + } +} diff --git a/crates/ghostty-input/src/keyboard.rs b/crates/ghostty-input/src/keyboard.rs new file mode 100644 index 0000000..ab4be8d --- /dev/null +++ b/crates/ghostty-input/src/keyboard.rs @@ -0,0 +1,56 @@ +//! Keyboard layouts (ZIG/src/input/keyboard.zig). + +use crate::config::OptionAsAlt; + +/// Keyboard layouts. +/// +/// These aren't heavily used in Ghostty and having a fully comprehensive +/// list is not important. We only need to distinguish between a few +/// different layouts for some nice-to-have features, such as setting a default +/// value for "macos-option-as-alt". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + /// Unknown, unmapped layout. Ghostty should not make any assumptions + /// about the layout of the keyboard. + Unknown, + + // The remaining should be fairly self-explanatory: + UsStandard, + UsInternational, +} + +impl Layout { + /// Map an Apple keyboard layout ID to a value in this enum. The layout + /// ID can be retrieved using Carbon's TIKeyboardLayoutGetInputSourceProperty + /// function. + /// + /// Even though our layout supports "unknown", we return None if we don't + /// recognize the layout ID so callers can detect this scenario. + pub fn map_apple_id(id: &str) -> Option { + match id { + "com.apple.keylayout.US" => Some(Layout::UsStandard), + "com.apple.keylayout.USInternational" => Some(Layout::UsInternational), + _ => None, + } + } + + /// Returns the default macos-option-as-alt value for this layout. + /// + /// We apply some heuristics to change the default based on the keyboard + /// layout if "macos-option-as-alt" is unset. We do this because on some + /// keyboard layouts such as US standard layouts, users generally expect + /// an input such as option-b to map to alt-b but macOS by default will + /// convert it to the codepoint "∫". + /// + /// This behavior however is desired on international layout where the + /// option key is used for important, regularly used inputs. + pub fn detect_option_as_alt(self) -> OptionAsAlt { + match self { + // On US standard, the option key is typically used as alt + // and not as a modifier for other codepoints. For example, + // option-B = ∫ but usually the user wants alt-B. + Layout::UsStandard | Layout::UsInternational => OptionAsAlt::True, + Layout::Unknown => OptionAsAlt::False, + } + } +} diff --git a/crates/ghostty-input/src/keycodes.rs b/crates/ghostty-input/src/keycodes.rs new file mode 100644 index 0000000..7749f91 --- /dev/null +++ b/crates/ghostty-input/src/keycodes.rs @@ -0,0 +1,697 @@ +// Based on the Chromium source. The Chromium source code is governed by a +// BSD-style license that can be found in the LICENSE file. +// https://source.chromium.org/chromium/chromium/src/+/main:ui/events/keycodes/dom/dom_code_data.inc + +//! Keycode table mapping USB/evdev/XKB/Windows/Mac codes to W3C DOM codes +//! (ZIG/src/input/keycodes.zig). The table is data-driven. + +use std::sync::OnceLock; + +use crate::key::Key; + +/// Entry contains the USB code, native keycode, and W3C dom code for +/// the current platform. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Entry { + /// input key enum + pub key: Key, + /// USB HID usage code + pub usb: u32, + /// Native keycode + pub native: u32, + /// W3C DOM code, static memory + pub code: &'static str, +} + +/// The full list of entries for the current platform (Linux: XKB codes). +pub fn entries() -> &'static [Entry] { + static ENTRIES: OnceLock> = OnceLock::new(); + ENTRIES.get_or_init(|| { + // Linux/FreeBSD use the xkb column (index 2). + RAW_ENTRIES + .iter() + .map(|raw| Entry { + key: code_to_key(raw.5).unwrap_or(Key::Unidentified), + usb: raw.0, + code: raw.5, + native: raw.2, + }) + .collect() + }) +} + +/// A map from code to key. This isn't meant to be used at runtime +/// (though it could), so it isn't exported. It it used to build the +/// key value for Entry. +fn code_to_key(code: &str) -> Option { + CODE_TO_KEY.iter().find(|e| e.0 == code).map(|e| e.1) +} + +const CODE_TO_KEY: &[(&str, Key)] = &[ + ("KeyA", Key::KeyA), + ("KeyB", Key::KeyB), + ("KeyC", Key::KeyC), + ("KeyD", Key::KeyD), + ("KeyE", Key::KeyE), + ("KeyF", Key::KeyF), + ("KeyG", Key::KeyG), + ("KeyH", Key::KeyH), + ("KeyI", Key::KeyI), + ("KeyJ", Key::KeyJ), + ("KeyK", Key::KeyK), + ("KeyL", Key::KeyL), + ("KeyM", Key::KeyM), + ("KeyN", Key::KeyN), + ("KeyO", Key::KeyO), + ("KeyP", Key::KeyP), + ("KeyQ", Key::KeyQ), + ("KeyR", Key::KeyR), + ("KeyS", Key::KeyS), + ("KeyT", Key::KeyT), + ("KeyU", Key::KeyU), + ("KeyV", Key::KeyV), + ("KeyW", Key::KeyW), + ("KeyX", Key::KeyX), + ("KeyY", Key::KeyY), + ("KeyZ", Key::KeyZ), + ("Digit1", Key::Digit1), + ("Digit2", Key::Digit2), + ("Digit3", Key::Digit3), + ("Digit4", Key::Digit4), + ("Digit5", Key::Digit5), + ("Digit6", Key::Digit6), + ("Digit7", Key::Digit7), + ("Digit8", Key::Digit8), + ("Digit9", Key::Digit9), + ("Digit0", Key::Digit0), + ("Enter", Key::Enter), + ("Escape", Key::Escape), + ("Backspace", Key::Backspace), + ("Tab", Key::Tab), + ("Space", Key::Space), + ("Minus", Key::Minus), + ("Equal", Key::Equal), + ("BracketLeft", Key::BracketLeft), + ("BracketRight", Key::BracketRight), + ("Backslash", Key::Backslash), + ("Semicolon", Key::Semicolon), + ("Quote", Key::Quote), + ("Backquote", Key::Backquote), + ("Comma", Key::Comma), + ("Period", Key::Period), + ("Slash", Key::Slash), + ("CapsLock", Key::CapsLock), + ("F1", Key::F1), + ("F2", Key::F2), + ("F3", Key::F3), + ("F4", Key::F4), + ("F5", Key::F5), + ("F6", Key::F6), + ("F7", Key::F7), + ("F8", Key::F8), + ("F9", Key::F9), + ("F10", Key::F10), + ("F11", Key::F11), + ("F12", Key::F12), + ("F13", Key::F13), + ("F14", Key::F14), + ("F15", Key::F15), + ("F16", Key::F16), + ("F17", Key::F17), + ("F18", Key::F18), + ("F19", Key::F19), + ("F20", Key::F20), + ("F21", Key::F21), + ("F22", Key::F22), + ("F23", Key::F23), + ("F24", Key::F24), + ("PrintScreen", Key::PrintScreen), + ("ScrollLock", Key::ScrollLock), + ("Pause", Key::Pause), + ("Insert", Key::Insert), + ("Home", Key::Home), + ("PageUp", Key::PageUp), + ("Delete", Key::Delete), + ("End", Key::End), + ("Copy", Key::Copy), + ("Cut", Key::Cut), + ("Paste", Key::Paste), + ("PageDown", Key::PageDown), + ("ArrowRight", Key::ArrowRight), + ("ArrowLeft", Key::ArrowLeft), + ("ArrowDown", Key::ArrowDown), + ("ArrowUp", Key::ArrowUp), + ("NumLock", Key::NumLock), + ("NumpadDivide", Key::NumpadDivide), + ("NumpadMultiply", Key::NumpadMultiply), + ("NumpadSubtract", Key::NumpadSubtract), + ("NumpadAdd", Key::NumpadAdd), + ("NumpadEnter", Key::NumpadEnter), + ("Numpad1", Key::Numpad1), + ("Numpad2", Key::Numpad2), + ("Numpad3", Key::Numpad3), + ("Numpad4", Key::Numpad4), + ("Numpad5", Key::Numpad5), + ("Numpad6", Key::Numpad6), + ("Numpad7", Key::Numpad7), + ("Numpad8", Key::Numpad8), + ("Numpad9", Key::Numpad9), + ("Numpad0", Key::Numpad0), + ("NumpadDecimal", Key::NumpadDecimal), + ("NumpadEqual", Key::NumpadEqual), + ("ContextMenu", Key::ContextMenu), + ("ControlLeft", Key::ControlLeft), + ("ShiftLeft", Key::ShiftLeft), + ("AltLeft", Key::AltLeft), + ("MetaLeft", Key::MetaLeft), + ("ControlRight", Key::ControlRight), + ("ShiftRight", Key::ShiftRight), + ("AltRight", Key::AltRight), + ("MetaRight", Key::MetaRight), +]; + +/// The codes for the table from the Chromium data set. These are ALL the +/// codes, not just the ones that are supported by the current platform. +/// These are `pub` but you shouldn't use this because it uses way more +/// memory than is necessary. +/// +/// The format is: usb, evdev, xkb, win, mac, code +pub type RawEntry = (u32, u32, u32, u32, u32, &'static str); + +/// All of the full entries. This is marked pub but it should NOT be referenced +/// directly because it contains too much data for normal usage. Use `entries` +/// instead which contains just the relevant data for the target platform. +#[rustfmt::skip] +pub const RAW_ENTRIES: &[RawEntry] = &[ + // USB evdev XKB Win Mac Code + (0x000000, 0x0000, 0x0000, 0x0000, 0xffff, ""), + + // ========================================= + // Non-USB codes + // ========================================= + + // USB evdev XKB Win Mac Code + (0x000010, 0x0000, 0x0000, 0x0000, 0xffff, "Hyper"), + (0x000011, 0x0000, 0x0000, 0x0000, 0xffff, "Super"), + (0x000012, 0x0000, 0x0000, 0x0000, 0xffff, "Fn"), + (0x000013, 0x0000, 0x0000, 0x0000, 0xffff, "FnLock"), + (0x000014, 0x0000, 0x0000, 0x0000, 0xffff, "Suspend"), + (0x000015, 0x0000, 0x0000, 0x0000, 0xffff, "Resume"), + (0x000016, 0x0000, 0x0000, 0x0000, 0xffff, "Turbo"), + + // ========================================= + // USB Usage Page 0x01: Generic Desktop Page + // ========================================= + + // Sleep could be encoded as USB#0c0032, but there's no corresponding WakeUp + // in the 0x0c USB page. + // USB evdev XKB Win Mac + (0x010082, 0x008e, 0x0096, 0xe05f, 0xffff, "Sleep"), + (0x010083, 0x008f, 0x0097, 0xe063, 0xffff, "WakeUp"), + (0x0100a9, 0x00f8, 0x0100, 0x0000, 0xffff, ""), + (0x0100b5, 0x00e3, 0x00eb, 0x0000, 0xffff, "DisplayToggleIntExt"), + + // ========================================= + // USB Usage Page 0x07: Keyboard/Keypad Page + // ========================================= + + // TODO(garykac): + // XKB#005c ISO Level3 Shift (AltGr) + // XKB#005e <>|| + // XKB#006d Linefeed + // XKB#008a SunProps cf. USB#0700a3 CrSel/Props + // XKB#008e SunOpen + // Mac#003f kVK_Function + // Mac#000a kVK_ISO_Section (ISO keyboards only) + // Mac#0066 kVK_JIS_Eisu (USB#07008a Henkan?) + + // USB evdev XKB Win Mac + (0x070000, 0x0000, 0x0000, 0x0000, 0xffff, ""), + (0x070001, 0x0000, 0x0000, 0x00ff, 0xffff, ""), + (0x070002, 0x0000, 0x0000, 0x00fc, 0xffff, ""), + (0x070003, 0x0000, 0x0000, 0x0000, 0xffff, ""), + (0x070004, 0x001e, 0x0026, 0x001e, 0x0000, "KeyA"), + (0x070005, 0x0030, 0x0038, 0x0030, 0x000b, "KeyB"), + (0x070006, 0x002e, 0x0036, 0x002e, 0x0008, "KeyC"), + (0x070007, 0x0020, 0x0028, 0x0020, 0x0002, "KeyD"), + + (0x070008, 0x0012, 0x001a, 0x0012, 0x000e, "KeyE"), + (0x070009, 0x0021, 0x0029, 0x0021, 0x0003, "KeyF"), + (0x07000a, 0x0022, 0x002a, 0x0022, 0x0005, "KeyG"), + (0x07000b, 0x0023, 0x002b, 0x0023, 0x0004, "KeyH"), + (0x07000c, 0x0017, 0x001f, 0x0017, 0x0022, "KeyI"), + (0x07000d, 0x0024, 0x002c, 0x0024, 0x0026, "KeyJ"), + (0x07000e, 0x0025, 0x002d, 0x0025, 0x0028, "KeyK"), + (0x07000f, 0x0026, 0x002e, 0x0026, 0x0025, "KeyL"), + + (0x070010, 0x0032, 0x003a, 0x0032, 0x002e, "KeyM"), + (0x070011, 0x0031, 0x0039, 0x0031, 0x002d, "KeyN"), + (0x070012, 0x0018, 0x0020, 0x0018, 0x001f, "KeyO"), + (0x070013, 0x0019, 0x0021, 0x0019, 0x0023, "KeyP"), + (0x070014, 0x0010, 0x0018, 0x0010, 0x000c, "KeyQ"), + (0x070015, 0x0013, 0x001b, 0x0013, 0x000f, "KeyR"), + (0x070016, 0x001f, 0x0027, 0x001f, 0x0001, "KeyS"), + (0x070017, 0x0014, 0x001c, 0x0014, 0x0011, "KeyT"), + + (0x070018, 0x0016, 0x001e, 0x0016, 0x0020, "KeyU"), + (0x070019, 0x002f, 0x0037, 0x002f, 0x0009, "KeyV"), + (0x07001a, 0x0011, 0x0019, 0x0011, 0x000d, "KeyW"), + (0x07001b, 0x002d, 0x0035, 0x002d, 0x0007, "KeyX"), + (0x07001c, 0x0015, 0x001d, 0x0015, 0x0010, "KeyY"), + (0x07001d, 0x002c, 0x0034, 0x002c, 0x0006, "KeyZ"), + (0x07001e, 0x0002, 0x000a, 0x0002, 0x0012, "Digit1"), + (0x07001f, 0x0003, 0x000b, 0x0003, 0x0013, "Digit2"), + + (0x070020, 0x0004, 0x000c, 0x0004, 0x0014, "Digit3"), + (0x070021, 0x0005, 0x000d, 0x0005, 0x0015, "Digit4"), + (0x070022, 0x0006, 0x000e, 0x0006, 0x0017, "Digit5"), + (0x070023, 0x0007, 0x000f, 0x0007, 0x0016, "Digit6"), + (0x070024, 0x0008, 0x0010, 0x0008, 0x001a, "Digit7"), + (0x070025, 0x0009, 0x0011, 0x0009, 0x001c, "Digit8"), + (0x070026, 0x000a, 0x0012, 0x000a, 0x0019, "Digit9"), + (0x070027, 0x000b, 0x0013, 0x000b, 0x001d, "Digit0"), + + (0x070028, 0x001c, 0x0024, 0x001c, 0x0024, "Enter"), + (0x070029, 0x0001, 0x0009, 0x0001, 0x0035, "Escape"), + (0x07002a, 0x000e, 0x0016, 0x000e, 0x0033, "Backspace"), + (0x07002b, 0x000f, 0x0017, 0x000f, 0x0030, "Tab"), + (0x07002c, 0x0039, 0x0041, 0x0039, 0x0031, "Space"), + (0x07002d, 0x000c, 0x0014, 0x000c, 0x001b, "Minus"), + (0x07002e, 0x000d, 0x0015, 0x000d, 0x0018, "Equal"), + (0x07002f, 0x001a, 0x0022, 0x001a, 0x0021, "BracketLeft"), + + (0x070030, 0x001b, 0x0023, 0x001b, 0x001e, "BracketRight"), + (0x070031, 0x002b, 0x0033, 0x002b, 0x002a, "Backslash"), + // USB#070032 never appears on keyboards that have USB#070031. + // Platforms use the same scancode as for the two keys. + // Hence this code can only be generated synthetically + // (e.g. in a DOM Level 3 KeyboardEvent). + // The keycap varies on international keyboards: + // Dan: '* Dutch: <> Ger: #' UK: #~ + // TODO(garykac): Verify Mac intl keyboard. + // .{ 0x070032, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + (0x070033, 0x0027, 0x002f, 0x0027, 0x0029, "Semicolon"), + (0x070034, 0x0028, 0x0030, 0x0028, 0x0027, "Quote"), + (0x070035, 0x0029, 0x0031, 0x0029, 0x0032, "Backquote"), + (0x070036, 0x0033, 0x003b, 0x0033, 0x002b, "Comma"), + (0x070037, 0x0034, 0x003c, 0x0034, 0x002f, "Period"), + + (0x070038, 0x0035, 0x003d, 0x0035, 0x002c, "Slash"), + // TODO(garykac): CapsLock requires special handling for each platform. + (0x070039, 0x003a, 0x0042, 0x003a, 0x0039, "CapsLock"), + (0x07003a, 0x003b, 0x0043, 0x003b, 0x007a, "F1"), + (0x07003b, 0x003c, 0x0044, 0x003c, 0x0078, "F2"), + (0x07003c, 0x003d, 0x0045, 0x003d, 0x0063, "F3"), + (0x07003d, 0x003e, 0x0046, 0x003e, 0x0076, "F4"), + (0x07003e, 0x003f, 0x0047, 0x003f, 0x0060, "F5"), + (0x07003f, 0x0040, 0x0048, 0x0040, 0x0061, "F6"), + + (0x070040, 0x0041, 0x0049, 0x0041, 0x0062, "F7"), + (0x070041, 0x0042, 0x004a, 0x0042, 0x0064, "F8"), + (0x070042, 0x0043, 0x004b, 0x0043, 0x0065, "F9"), + (0x070043, 0x0044, 0x004c, 0x0044, 0x006d, "F10"), + (0x070044, 0x0057, 0x005f, 0x0057, 0x0067, "F11"), + (0x070045, 0x0058, 0x0060, 0x0058, 0x006f, "F12"), + // PrintScreen is effectively F13 on Mac OS X. + (0x070046, 0x0063, 0x006b, 0xe037, 0xffff, "PrintScreen"), + (0x070047, 0x0046, 0x004e, 0x0046, 0xffff, "ScrollLock"), + + (0x070048, 0x0077, 0x007f, 0x0045, 0xffff, "Pause"), + // USB#0x070049 Insert, labeled "Help/Insert" on Mac -- see note M1 at top. + (0x070049, 0x006e, 0x0076, 0xe052, 0x0072, "Insert"), + (0x07004a, 0x0066, 0x006e, 0xe047, 0x0073, "Home"), + (0x07004b, 0x0068, 0x0070, 0xe049, 0x0074, "PageUp"), + // Delete (Forward Delete) named DEL because DELETE conflicts with + (0x07004c, 0x006f, 0x0077, 0xe053, 0x0075, "Delete"), + (0x07004d, 0x006b, 0x0073, 0xe04f, 0x0077, "End"), + (0x07004e, 0x006d, 0x0075, 0xe051, 0x0079, "PageDown"), + (0x07004f, 0x006a, 0x0072, 0xe04d, 0x007c, "ArrowRight"), + + (0x070050, 0x0069, 0x0071, 0xe04b, 0x007b, "ArrowLeft"), + (0x070051, 0x006c, 0x0074, 0xe050, 0x007d, "ArrowDown"), + (0x070052, 0x0067, 0x006f, 0xe048, 0x007e, "ArrowUp"), + (0x070053, 0x0045, 0x004d, 0xe045, 0x0047, "NumLock"), + (0x070054, 0x0062, 0x006a, 0xe035, 0x004b, "NumpadDivide"), + (0x070055, 0x0037, 0x003f, 0x0037, 0x0043, "NumpadMultiply"), + (0x070056, 0x004a, 0x0052, 0x004a, 0x004e, "NumpadSubtract"), + (0x070057, 0x004e, 0x0056, 0x004e, 0x0045, "NumpadAdd"), + + (0x070058, 0x0060, 0x0068, 0xe01c, 0x004c, "NumpadEnter"), + (0x070059, 0x004f, 0x0057, 0x004f, 0x0053, "Numpad1"), // +End + (0x07005a, 0x0050, 0x0058, 0x0050, 0x0054, "Numpad2"), // +Down + (0x07005b, 0x0051, 0x0059, 0x0051, 0x0055, "Numpad3"), // +PageDown + (0x07005c, 0x004b, 0x0053, 0x004b, 0x0056, "Numpad4"), // +Left + (0x07005d, 0x004c, 0x0054, 0x004c, 0x0057, "Numpad5"), + (0x07005e, 0x004d, 0x0055, 0x004d, 0x0058, "Numpad6"), // +Right + (0x07005f, 0x0047, 0x004f, 0x0047, 0x0059, "Numpad7"), // +Home + (0x070060, 0x0048, 0x0050, 0x0048, 0x005b, "Numpad8"), // +Up + (0x070061, 0x0049, 0x0051, 0x0049, 0x005c, "Numpad9"), // +PageUp + (0x070062, 0x0052, 0x005a, 0x0052, 0x0052, "Numpad0"), // +Insert + (0x070063, 0x0053, 0x005b, 0x0053, 0x0041, "NumpadDecimal"), // +Delete + // USB#070064 is not present on US keyboard. + // This key is typically located near LeftShift key. + // The keycap varies on international keyboards: + // Dan: <> Dutch: ][ Ger: <> UK: \| + (0x070064, 0x0056, 0x005e, 0x0056, 0x000a, "IntlBackslash"), + // USB#0x070065 Application Menu (next to RWin key) -- see note L2 at top. + (0x070065, 0x007f, 0x0087, 0xe05d, 0x006e, "ContextMenu"), + (0x070066, 0x0074, 0x007c, 0xe05e, 0xffff, "Power"), + (0x070067, 0x0075, 0x007d, 0x0059, 0x0051, "NumpadEqual"), + + (0x070068, 0x00b7, 0x00bf, 0x0064, 0x0069, "F13"), + (0x070069, 0x00b8, 0x00c0, 0x0065, 0x006b, "F14"), + (0x07006a, 0x00b9, 0x00c1, 0x0066, 0x0071, "F15"), + (0x07006b, 0x00ba, 0x00c2, 0x0067, 0x006a, "F16"), + (0x07006c, 0x00bb, 0x00c3, 0x0068, 0x0040, "F17"), + (0x07006d, 0x00bc, 0x00c4, 0x0069, 0x004f, "F18"), + (0x07006e, 0x00bd, 0x00c5, 0x006a, 0x0050, "F19"), + (0x07006f, 0x00be, 0x00c6, 0x006b, 0x005a, "F20"), + + (0x070070, 0x00bf, 0x00c7, 0x006c, 0xffff, "F21"), + (0x070071, 0x00c0, 0x00c8, 0x006d, 0xffff, "F22"), + (0x070072, 0x00c1, 0x00c9, 0x006e, 0xffff, "F23"), + // USB#0x070073 -- see note W1 at top. + (0x070073, 0x00c2, 0x00ca, 0x0076, 0xffff, "F24"), + (0x070074, 0x0086, 0x008e, 0x0000, 0xffff, "Open"), + // USB#0x070075 Help -- see note M1 at top. + (0x070075, 0x008a, 0x0092, 0xe03b, 0xffff, "Help"), + // USB#0x070076 Keyboard Menu -- see note L2 at top. + // .{ 0x070076, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + (0x070077, 0x0084, 0x008c, 0x0000, 0xffff, "Select"), + + // .{ 0x070078, 0x0080, 0x0088, 0x0000, 0xffff, ""}, + (0x070079, 0x0081, 0x0089, 0x0000, 0xffff, "Again"), + (0x07007a, 0x0083, 0x008b, 0xe008, 0xffff, "Undo"), + (0x07007b, 0x0089, 0x0091, 0xe017, 0xffff, "Cut"), + (0x07007c, 0x0085, 0x008d, 0xe018, 0xffff, "Copy"), + (0x07007d, 0x0087, 0x008f, 0xe00a, 0xffff, "Paste"), + (0x07007e, 0x0088, 0x0090, 0x0000, 0xffff, "Find"), + (0x07007f, 0x0071, 0x0079, 0xe020, 0x004a, "AudioVolumeMute"), + + (0x070080, 0x0073, 0x007b, 0xe030, 0x0048, "AudioVolumeUp"), + (0x070081, 0x0072, 0x007a, 0xe02e, 0x0049, "AudioVolumeDown"), + // .{ 0x070082, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x070083, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x070084, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + (0x070085, 0x0079, 0x0081, 0x007e, 0x005f, "NumpadComma"), + + // International1 + // USB#070086 is used on AS/400 keyboards. Standard Keypad_= is USB#070067. + // .{ 0x070086, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#070087 is used for Brazilian /? and Japanese _ 'ro'. + (0x070087, 0x0059, 0x0061, 0x0073, 0x005e, "IntlRo"), + // International2 + // USB#070088 is used as Japanese Hiragana/Katakana key. + (0x070088, 0x005d, 0x0065, 0x0070, 0xffff, "KanaMode"), + // International3 + // USB#070089 is used as Japanese Yen key. + (0x070089, 0x007c, 0x0084, 0x007d, 0x005d, "IntlYen"), + // International4 + // USB#07008a is used as Japanese Henkan (Convert) key. + (0x07008a, 0x005c, 0x0064, 0x0079, 0xffff, "Convert"), + // International5 + // USB#07008b is used as Japanese Muhenkan (No-convert) key. + (0x07008b, 0x005e, 0x0066, 0x007b, 0xffff, "NonConvert"), + // .{ 0x07008c, 0x005f, 0x0067, 0x005c, 0xffff, ""}, + // .{ 0x07008d, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x07008e, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x07008f, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // LANG1 + // USB#070090 is used as Korean Hangul/English toggle key, and as the Kana key + // on the Apple Japanese keyboard. + (0x070090, 0x007a, 0x0082, 0x0072, 0x0068, "Lang1"), + // LANG2 + // USB#070091 is used as Korean Hanja conversion key, and as the Eisu key on + // the Apple Japanese keyboard. + (0x070091, 0x007b, 0x0083, 0x0071, 0x0066, "Lang2"), + // LANG3 + // USB#070092 is used as Japanese Katakana key. + (0x070092, 0x005a, 0x0062, 0x0078, 0xffff, "Lang3"), + // LANG4 + // USB#070093 is used as Japanese Hiragana key. + (0x070093, 0x005b, 0x0063, 0x0077, 0xffff, "Lang4"), + // LANG5 + // USB#070094 is used as Japanese Zenkaku/Hankaku (Fullwidth/halfwidth) key. + // Not mapped on Windows -- see note W1 at top. + (0x070094, 0x0055, 0x005d, 0x0000, 0xffff, "Lang5"), + // .{ 0x070095, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x070096, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x070097, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x070098, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // .{ 0x070099, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x07009a, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + (0x07009b, 0x0000, 0x0000, 0x0000, 0xffff, "Abort"), + // USB#0x07009c Keyboard Clear -- see note L1 at top. + // .{ 0x07009c, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x07009d, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x07009e, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x07009f, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // .{ 0x0700a0, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700a1, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700a2, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#0x0700a3 Props -- see note L2 at top. + (0x0700a3, 0x0000, 0x0000, 0x0000, 0xffff, "Props"), + // .{ 0x0700a4, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // .{ 0x0700b0, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700b1, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700b2, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700b3, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700b4, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700b5, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + (0x0700b6, 0x00b3, 0x00bb, 0x0000, 0xffff, "NumpadParenLeft"), + (0x0700b7, 0x00b4, 0x00bc, 0x0000, 0xffff, "NumpadParenRight"), + + // .{ 0x0700b8, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700b9, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700ba, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + (0x0700bb, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadBackspace"), + // .{ 0x0700bc, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700bd, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700be, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700bf, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // .{ 0x0700c0, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700c1, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700c2, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700c3, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700c4, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700c5, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700c6, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700c7, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // .{ 0x0700c8, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700c9, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700ca, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // NUMPAD_DOUBLE_VERTICAL_BAR), // Keypad_|| + // .{ 0x0700cb, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700cc, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700cd, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700ce, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700cf, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + (0x0700d0, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemoryStore"), + (0x0700d1, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemoryRecall"), + (0x0700d2, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemoryClear"), + (0x0700d3, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemoryAdd"), + (0x0700d4, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadMemorySubtract"), + // .{ 0x0700d5, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700d6, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + (0x0700d7, 0x0076, 0x007e, 0x0000, 0xffff, ""), + // USB#0x0700d8 Keypad Clear -- see note L1 at top. + (0x0700d8, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadClear"), + (0x0700d9, 0x0000, 0x0000, 0x0000, 0xffff, "NumpadClearEntry"), + // .{ 0x0700da, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700db, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700dc, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0700dd, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + + // USB#0700de - #0700df are reserved. + (0x0700e0, 0x001d, 0x0025, 0x001d, 0x003b, "ControlLeft"), + (0x0700e1, 0x002a, 0x0032, 0x002a, 0x0038, "ShiftLeft"), + // USB#0700e2: left Alt key (Mac left Option key). + (0x0700e2, 0x0038, 0x0040, 0x0038, 0x003a, "AltLeft"), + // USB#0700e3: left GUI key, e.g. Windows, Mac Command, ChromeOS Search. + (0x0700e3, 0x007d, 0x0085, 0xe05b, 0x0037, "MetaLeft"), + (0x0700e4, 0x0061, 0x0069, 0xe01d, 0x003e, "ControlRight"), + (0x0700e5, 0x0036, 0x003e, 0x0036, 0x003c, "ShiftRight"), + // USB#0700e6: right Alt key (Mac right Option key). + (0x0700e6, 0x0064, 0x006c, 0xe038, 0x003d, "AltRight"), + // USB#0700e7: right GUI key, e.g. Windows, Mac Command, ChromeOS Search. + (0x0700e7, 0x007e, 0x0086, 0xe05c, 0x0036, "MetaRight"), + + // USB#0700e8 - #07ffff are reserved + + // ================================== + // USB Usage Page 0x0c: Consumer Page + // ================================== + // AL = Application Launch + // AC = Application Control + + // TODO(garykac): Many XF86 keys have multiple scancodes mapping to them. + // We need to map all of these into a canonical USB scancode without + // confusing the reverse-lookup - most likely by simply returning the first + // found match. + + // TODO(garykac): Find appropriate mappings for: + // Win#e03c Music - USB#0c0193 is AL_AVCapturePlayback + // Win#e064 Pictures + // XKB#0080 XF86LaunchA + // XKB#0099 XF86Send + // XKB#009b XF86Xfer + // XKB#009c XF86Launch1 + // XKB#009d XF86Launch2 + // XKB... remaining XF86 keys + + // KEY_BRIGHTNESS* added in Linux 3.16 + // http://www.usb.org/developers/hidpage/HUTRR41.pdf + // + // Keyboard backlight/illumination spec update. + // https://www.usb.org/sites/default/files/hutrr73_-_fn_key_and_keyboard_backlight_brightness_0.pdf + // USB evdev XKB Win Mac Code + (0x0c0060, 0x0166, 0x016e, 0x0000, 0xffff, ""), + (0x0c0061, 0x0172, 0x017a, 0x0000, 0xffff, ""), + (0x0c006f, 0x00e1, 0x00e9, 0x0000, 0xffff, "BrightnessUp"), + (0x0c0070, 0x00e0, 0x00e8, 0x0000, 0xffff, "BrightnessDown"), + (0x0c0072, 0x01af, 0x01b7, 0x0000, 0xffff, ""), + (0x0c0073, 0x0250, 0x0258, 0x0000, 0xffff, ""), + (0x0c0074, 0x0251, 0x0259, 0x0000, 0xffff, ""), + (0x0c0075, 0x00f4, 0x00fc, 0x0000, 0xffff, ""), + (0x0c0079, 0x00e6, 0x00ee, 0x0000, 0xffff, ""), + (0x0c007a, 0x00e5, 0x00ed, 0x0000, 0xffff, ""), + (0x0c007c, 0x00e4, 0x00ec, 0x0000, 0xffff, ""), + (0x0c0083, 0x0195, 0x019d, 0x0000, 0xffff, ""), + (0x0c008c, 0x00a9, 0x00b1, 0x0000, 0xffff, ""), + (0x0c008d, 0x016a, 0x0172, 0x0000, 0xffff, ""), + (0x0c0094, 0x00ae, 0x00b6, 0x0000, 0xffff, ""), + (0x0c009c, 0x019a, 0x01a2, 0x0000, 0xffff, ""), + (0x0c009d, 0x019b, 0x01a3, 0x0000, 0xffff, ""), + + // USB evdev XKB Win Mac + (0x0c00b0, 0x00cf, 0x00d7, 0x0000, 0xffff, "MediaPlay"), + (0x0c00b1, 0x00c9, 0x00d1, 0x0000, 0xffff, "MediaPause"), + (0x0c00b2, 0x00a7, 0x00af, 0x0000, 0xffff, "MediaRecord"), + (0x0c00b3, 0x00d0, 0x00d8, 0x0000, 0xffff, "MediaFastForward"), + (0x0c00b4, 0x00a8, 0x00b0, 0x0000, 0xffff, "MediaRewind"), + (0x0c00b5, 0x00a3, 0x00ab, 0xe019, 0xffff, "MediaTrackNext"), + (0x0c00b6, 0x00a5, 0x00ad, 0xe010, 0xffff, "MediaTrackPrevious"), + (0x0c00b7, 0x00a6, 0x00ae, 0xe024, 0xffff, "MediaStop"), + (0x0c00b8, 0x00a1, 0x00a9, 0xe02c, 0xffff, "Eject"), + (0x0c00cd, 0x00a4, 0x00ac, 0xe022, 0xffff, "MediaPlayPause"), + (0x0c00cf, 0x0246, 0x024e, 0x0000, 0xffff, ""), + (0x0c00d8, 0x024a, 0x0252, 0x0000, 0xffff, ""), + (0x0c00d9, 0x0249, 0x0251, 0x0000, 0xffff, ""), + (0x0c00e5, 0x00d1, 0x00d9, 0x0000, 0xffff, ""), + // .{ 0x0c00e6, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0c0150, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0c0151, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0c0152, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0c0153, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0c0154, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // .{ 0x0c0155, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#0c0183: AL Consumer Control Configuration + (0x0c0183, 0x00ab, 0x00b3, 0xe06d, 0xffff, "MediaSelect"), + (0x0c0184, 0x01a5, 0x01ad, 0x0000, 0xffff, ""), + (0x0c0186, 0x01a7, 0x01af, 0x0000, 0xffff, ""), + // USB#0x0c018a AL_EmailReader + (0x0c018a, 0x009b, 0x00a3, 0xe06c, 0xffff, "LaunchMail"), + // USB#0x0c018d: AL Contacts/Address Book + (0x0c018d, 0x01ad, 0x01b5, 0x0000, 0xffff, ""), + // USB#0x0c018e: AL Calendar/Schedule + (0x0c018e, 0x018d, 0x0195, 0x0000, 0xffff, ""), + // USB#0x0c018f AL Task/Project Manager + // .{ 0x0c018f, 0x0241, 0x0249, 0x0000, 0xffff, ""}, + // USB#0x0c0190: AL Log/Journal/Timecard + // .{ 0x0c0190, 0x0242, 0x024a, 0x0000, 0xffff, ""}, + // USB#0x0c0192: AL_Calculator + (0x0c0192, 0x008c, 0x0094, 0xe021, 0xffff, "LaunchApp2"), + // USB#0c0194: My Computer (AL_LocalMachineBrowser) + (0x0c0194, 0x0090, 0x0098, 0xe06b, 0xffff, "LaunchApp1"), + (0x0c0196, 0x0096, 0x009e, 0x0000, 0xffff, ""), + (0x0c019c, 0x01b1, 0x01b9, 0x0000, 0xffff, ""), + // USB#0x0c019e: AL Terminal Lock/Screensaver + (0x0c019e, 0x0098, 0x00a0, 0x0000, 0xffff, ""), + // USB#0x0c019f AL Control Panel + (0x0c019f, 0x0243, 0x024b, 0x0000, 0xffff, "LaunchControlPanel"), + // USB#0x0c01a2: AL Select Task/Application + (0x0c01a2, 0x0244, 0x024c, 0x0000, 0xffff, "SelectTask"), + // USB#0x0c01a7: AL_Documents + (0x0c01a7, 0x00eb, 0x00f3, 0x0000, 0xffff, ""), + (0x0c01ab, 0x01b0, 0x01b8, 0x0000, 0xffff, ""), + // USB#0x0c01ae: AL Keyboard Layout + (0x0c01ae, 0x0176, 0x017e, 0x0000, 0xffff, ""), + (0x0c01b1, 0x0245, 0x024d, 0x0000, 0xffff, "LaunchScreenSaver"), + (0x0c01cb, 0x0247, 0x024f, 0x0000, 0xffff, "LaunchAssistant"), + // USB#0c01b4: Home Directory (AL_FileBrowser) (Explorer) + // .{ 0x0c01b4, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#0x0c01b7: AL Audio Browser + (0x0c01b7, 0x0188, 0x0190, 0x0000, 0xffff, ""), + // USB#0x0c0201: AC New + (0x0c0201, 0x00b5, 0x00bd, 0x0000, 0xffff, ""), + // USB#0x0c0203: AC Close + (0x0c0203, 0x00ce, 0x00d6, 0x0000, 0xffff, ""), + // USB#0x0c0207: AC Close + (0x0c0207, 0x00ea, 0x00f2, 0x0000, 0xffff, ""), + // USB#0x0c0208: AC Print + (0x0c0208, 0x00d2, 0x00da, 0x0000, 0xffff, ""), + // USB#0x0c0221: AC_Search + (0x0c0221, 0x00d9, 0x00e1, 0xe065, 0xffff, "BrowserSearch"), + // USB#0x0c0223: AC_Home + (0x0c0223, 0x00ac, 0x00b4, 0xe032, 0xffff, "BrowserHome"), + // USB#0x0c0224: AC_Back + (0x0c0224, 0x009e, 0x00a6, 0xe06a, 0xffff, "BrowserBack"), + // USB#0x0c0225: AC_Forward + (0x0c0225, 0x009f, 0x00a7, 0xe069, 0xffff, "BrowserForward"), + // USB#0x0c0226: AC_Stop + (0x0c0226, 0x0080, 0x0088, 0xe068, 0xffff, "BrowserStop"), + // USB#0x0c0227: AC_Refresh (Reload) + (0x0c0227, 0x00ad, 0x00b5, 0xe067, 0xffff, "BrowserRefresh"), + // USB#0x0c022a: AC_Bookmarks (Favorites) + (0x0c022a, 0x009c, 0x00a4, 0xe066, 0xffff, "BrowserFavorites"), + (0x0c022d, 0x01a2, 0x01aa, 0x0000, 0xffff, ""), + (0x0c022e, 0x01a3, 0x01ab, 0x0000, 0xffff, ""), + // USB#0x0c0230: AC Full Screen View + // .{ 0x0c0230, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + // USB#0x0c0231: AC Normal View + // .{ 0x0c0231, 0x0000, 0x0000, 0x0000, 0xffff, ""}, + (0x0c0232, 0x0174, 0x017c, 0x0000, 0xffff, "ZoomToggle"), + // USB#0x0c0279: AC Redo/Repeat + (0x0c0279, 0x00b6, 0x00be, 0x0000, 0xffff, ""), + // USB#0x0c0289: AC_Reply + (0x0c0289, 0x00e8, 0x00f0, 0x0000, 0xffff, "MailReply"), + // USB#0x0c028b: AC_ForwardMsg (MailForward) + (0x0c028b, 0x00e9, 0x00f1, 0x0000, 0xffff, "MailForward"), + // USB#0x0c028c: AC_Send + (0x0c028c, 0x00e7, 0x00ef, 0x0000, 0xffff, "MailSend"), + // USB#0x0c029d: AC Next Keyboard Layout Select + (0x0c029d, 0x0248, 0x0250, 0x0000, 0xffff, "KeyboardLayoutSelect"), + (0x0c029f, 0x0078, 0x0080, 0x0000, 0xffff, "ShowAllWindows"), + (0x0c02a2, 0x00cc, 0x00d4, 0x0000, 0xffff, ""), + (0x0c02d0, 0x0279, 0x0281, 0x0000, 0xffff, ""), +]; + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn entries_build() { + let e = entries(); + assert_eq!(e.len(), RAW_ENTRIES.len()); + let a = e.iter().find(|e| e.code == "KeyA").unwrap(); + assert_eq!(a.key, Key::KeyA); + assert_eq!(a.usb, 0x070004); + assert_eq!(a.native, 0x0026); + } + + #[test] + fn code_to_key_keys_are_w3c_names() { + for (code, key) in CODE_TO_KEY { + assert_eq!(Key::from_w3c(code), Some(*key), "{code}"); + } + } +} diff --git a/crates/ghostty-input/src/kitty.rs b/crates/ghostty-input/src/kitty.rs new file mode 100644 index 0000000..f94acb4 --- /dev/null +++ b/crates/ghostty-input/src/kitty.rs @@ -0,0 +1,134 @@ +//! Kitty keyboard protocol key mapping data (ZIG/src/input/kitty.zig). + +use crate::key::Key; + +/// A single entry in the kitty keymap data. There are only ~100 entries +/// so the recommendation is to just use a linear search to find the entry +/// for a given key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Entry { + pub key: Key, + pub code: u32, + pub final_: u8, + pub modifier: bool, +} + +const fn e(key: Key, code: u32, final_: u8, modifier: bool) -> Entry { + Entry { + key, + code, + final_, + modifier, + } +} + +/// The raw data for how to map keys to Kitty data. Based on the information: +/// https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions +/// And the exact table is ported from Foot: +/// https://codeberg.org/dnkl/foot/src/branch/master/kitty-keymap.h +/// +/// Note that we currently don't support all the same keysyms as Kitty, +/// but we can add them as we add support. +/// +/// These are stored in order of the above Kitty link, so it is easy to +/// find the entry for a given key. +pub const ENTRIES: &[Entry] = &[ + e(Key::Escape, 27, b'u', false), + e(Key::Enter, 13, b'u', false), + e(Key::Tab, 9, b'u', false), + e(Key::Backspace, 127, b'u', false), + e(Key::Insert, 2, b'~', false), + e(Key::Delete, 3, b'~', false), + e(Key::ArrowLeft, 1, b'D', false), + e(Key::ArrowRight, 1, b'C', false), + e(Key::ArrowUp, 1, b'A', false), + e(Key::ArrowDown, 1, b'B', false), + e(Key::PageUp, 5, b'~', false), + e(Key::PageDown, 6, b'~', false), + e(Key::Home, 1, b'H', false), + e(Key::End, 1, b'F', false), + e(Key::CapsLock, 57358, b'u', true), + e(Key::ScrollLock, 57359, b'u', false), + e(Key::NumLock, 57360, b'u', true), + e(Key::PrintScreen, 57361, b'u', false), + e(Key::Pause, 57362, b'u', false), + e(Key::F1, 1, b'P', false), + e(Key::F2, 1, b'Q', false), + e(Key::F3, 13, b'~', false), + e(Key::F4, 1, b'S', false), + e(Key::F5, 15, b'~', false), + e(Key::F6, 17, b'~', false), + e(Key::F7, 18, b'~', false), + e(Key::F8, 19, b'~', false), + e(Key::F9, 20, b'~', false), + e(Key::F10, 21, b'~', false), + e(Key::F11, 23, b'~', false), + e(Key::F12, 24, b'~', false), + e(Key::F13, 57376, b'u', false), + e(Key::F14, 57377, b'u', false), + e(Key::F15, 57378, b'u', false), + e(Key::F16, 57379, b'u', false), + e(Key::F17, 57380, b'u', false), + e(Key::F18, 57381, b'u', false), + e(Key::F19, 57382, b'u', false), + e(Key::F20, 57383, b'u', false), + e(Key::F21, 57384, b'u', false), + e(Key::F22, 57385, b'u', false), + e(Key::F23, 57386, b'u', false), + e(Key::F24, 57387, b'u', false), + e(Key::F25, 57388, b'u', false), + e(Key::Numpad0, 57399, b'u', false), + e(Key::Numpad1, 57400, b'u', false), + e(Key::Numpad2, 57401, b'u', false), + e(Key::Numpad3, 57402, b'u', false), + e(Key::Numpad4, 57403, b'u', false), + e(Key::Numpad5, 57404, b'u', false), + e(Key::Numpad6, 57405, b'u', false), + e(Key::Numpad7, 57406, b'u', false), + e(Key::Numpad8, 57407, b'u', false), + e(Key::Numpad9, 57408, b'u', false), + e(Key::NumpadDecimal, 57409, b'u', false), + e(Key::NumpadDivide, 57410, b'u', false), + e(Key::NumpadMultiply, 57411, b'u', false), + e(Key::NumpadSubtract, 57412, b'u', false), + e(Key::NumpadAdd, 57413, b'u', false), + e(Key::NumpadEnter, 57414, b'u', false), + e(Key::NumpadEqual, 57415, b'u', false), + e(Key::NumpadSeparator, 57416, b'u', false), + e(Key::NumpadLeft, 57417, b'u', false), + e(Key::NumpadRight, 57418, b'u', false), + e(Key::NumpadUp, 57419, b'u', false), + e(Key::NumpadDown, 57420, b'u', false), + e(Key::NumpadPageUp, 57421, b'u', false), + e(Key::NumpadPageDown, 57422, b'u', false), + e(Key::NumpadHome, 57423, b'u', false), + e(Key::NumpadEnd, 57424, b'u', false), + e(Key::NumpadInsert, 57425, b'u', false), + e(Key::NumpadDelete, 57426, b'u', false), + e(Key::NumpadBegin, 57427, b'u', false), + e(Key::ShiftLeft, 57441, b'u', true), + e(Key::ShiftRight, 57447, b'u', true), + e(Key::ControlLeft, 57442, b'u', true), + e(Key::ControlRight, 57448, b'u', true), + e(Key::MetaLeft, 57444, b'u', true), + e(Key::MetaRight, 57450, b'u', true), + e(Key::AltLeft, 57443, b'u', true), + e(Key::AltRight, 57449, b'u', true), +]; + +/// The full list of entries for the current platform. +pub fn entries() -> &'static [Entry] { + ENTRIES +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn entries_table() { + // To force evaluation of the table + assert_ne!(entries().len(), 0); + } +} diff --git a/crates/ghostty-input/src/lib.rs b/crates/ghostty-input/src/lib.rs index 9cf96bc..4d7e2ae 100644 --- a/crates/ghostty-input/src/lib.rs +++ b/crates/ghostty-input/src/lib.rs @@ -1 +1,26 @@ -//! ghostty-input: see docs/rewrite/ARCHITECTURE.md for the module map. +//! ghostty-input: keyboard/mouse input handling (ZIG/src/input/*). + +pub mod binding; +pub mod command; +pub mod config; +pub mod function_keys; +pub mod helpgen_actions; +pub mod key; +pub mod key_encode; +pub mod key_mods; +pub mod keyboard; +pub mod keycodes; +pub mod kitty; +pub mod link; +pub mod mouse; +pub mod paste; + +pub use config::OptionAsAlt; +pub use key::{Action, Key, KeyEvent, ctrl_or_super}; +pub use key_mods::{Mods, RemapSet as KeyRemapSet}; +pub use keyboard::Layout as KeyboardLayout; +pub use link::Link; +pub use mouse::{ + Button as MouseButton, ButtonState as MouseButtonState, PressureStage as MousePressureStage, + ScrollMods, +}; diff --git a/crates/ghostty-input/src/link.rs b/crates/ghostty-input/src/link.rs new file mode 100644 index 0000000..8662597 --- /dev/null +++ b/crates/ghostty-input/src/link.rs @@ -0,0 +1,63 @@ +//! A link is a clickable element that can be used to trigger some action. +//! A link is NOT just a URL that opens in a browser. A link is any generic +//! regular expression match over terminal text that can trigger various +//! action types. (ZIG/src/input/Link.zig) + +use crate::key_mods::Mods; + +/// The action that will be triggered when the link is clicked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Open the full matched value using the default open program. + /// For example, on macOS this is "open" and on Linux this is "xdg-open". + Open, + + /// Open the OSC8 hyperlink under the mouse position. _-prefixed means + /// this can't be user-specified, it's only used internally. + _OpenOsc8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Highlight { + /// Always highlight the link. + Always, + + /// Only highlight the link when the mouse is hovering over it. + Hover, + + /// Highlight anytime the given mods are pressed, either when + /// hovering or always. For always, all links will be highlighted + /// when the mods are pressed regardless of if the mouse is hovering + /// over them. + /// + /// Note that if "shift" is specified here, this will NEVER match in + /// TUI programs that capture mouse events. "Shift" with mouse capture + /// escapes the mouse capture but strips the "shift" so it can't be + /// detected. + AlwaysMods(Mods), + HoverMods(Mods), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Link { + /// The regular expression that will be used to match the link. + pub regex: String, + + /// The action that will be triggered when the link is clicked. + pub action: Action, + + /// The situations in which the link will be highlighted. A link is only + /// clickable by the mouse when it is highlighted, so this also controls + /// when the link is clickable. + pub highlight: Highlight, +} + +// TODO(integration): `oni_regex` (Zig `oniRegex`) builds an Oniguruma regex; +// the regex engine choice is made by the consumer crate (config/core). + +impl Link { + /// Check if two links are equal. + pub fn equal(&self, other: &Link) -> bool { + self == other + } +} diff --git a/crates/ghostty-input/src/mouse.rs b/crates/ghostty-input/src/mouse.rs new file mode 100644 index 0000000..fad2f02 --- /dev/null +++ b/crates/ghostty-input/src/mouse.rs @@ -0,0 +1,132 @@ +//! Mouse input types (ZIG/src/input/mouse.zig). + +/// The state of a mouse button. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum ButtonState { + Release, + Press, +} + +/// Possible mouse buttons. We only track up to 11 because that's the maximum +/// button input that terminal mouse tracking handles without becoming +/// ambiguous. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum Button { + Unknown = 0, + Left = 1, + Right = 2, + Middle = 3, + Four = 4, + Five = 5, + Six = 6, + Seven = 7, + Eight = 8, + Nine = 9, + Ten = 10, + Eleven = 11, +} + +impl Button { + /// The maximum value in this enum. This can be used to create a densely + /// packed array, for example. + pub const MAX: i32 = 11; +} + +/// The "momentum" of a mouse scroll event. This matches the macOS events +/// because it is the only reliable source right now of momentum events. +/// This is used to handle "inertial scrolling" (i.e. flicking). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[repr(u8)] +pub enum Momentum { + #[default] + None = 0, + Began = 1, + Stationary = 2, + Changed = 3, + Ended = 4, + Cancelled = 5, + MayBegin = 6, +} + +impl Momentum { + pub fn from_int(v: u8) -> Option { + Some(match v { + 0 => Momentum::None, + 1 => Momentum::Began, + 2 => Momentum::Stationary, + 3 => Momentum::Changed, + 4 => Momentum::Ended, + 5 => Momentum::Cancelled, + 6 => Momentum::MayBegin, + _ => return None, + }) + } +} + +/// The pressure stage of a pressure-sensitive input device. +/// +/// This currently only supports the stages that macOS supports. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[repr(u8)] +pub enum PressureStage { + /// The input device is unpressed. + #[default] + None = 0, + + /// The input device is pressed a normal amount. On macOS trackpads, + /// this is after a "click". + Normal = 1, + + /// The input device is pressed a deep amount. On macOS trackpads, + /// this is after a "force click". + Deep = 2, +} + +/// The bitmask for mods for scroll events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct ScrollMods { + /// True if this is a high-precision scroll event. For example, Apple + /// devices such as Magic Mouse, trackpads, etc. are high-precision + /// and send very detailed scroll events. + pub precision: bool, + + /// The momentum phase (if available, supported) of the scroll event. + /// This is used to handle "inertial scrolling" (i.e. flicking). + pub momentum: Momentum, +} + +impl ScrollMods { + /// Packed `u8` representation (bit 0 precision, bits 1..4 momentum). + pub fn int(self) -> u8 { + u8::from(self.precision) | ((self.momentum as u8) << 1) + } + + pub fn from_int(v: u8) -> ScrollMods { + ScrollMods { + precision: v & 1 != 0, + momentum: Momentum::from_int((v >> 1) & 0b111).unwrap_or(Momentum::None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // For our own understanding + #[test] + fn scroll_mods_layout() { + assert_eq!(ScrollMods::default().int(), 0b0); + assert_eq!( + ScrollMods { + precision: true, + ..Default::default() + } + .int(), + 0b0000_0001 + ); + assert_eq!(ScrollMods::from_int(0b0000_0011).momentum, Momentum::Began); + } +} diff --git a/crates/ghostty-input/src/paste.rs b/crates/ghostty-input/src/paste.rs new file mode 100644 index 0000000..80af310 --- /dev/null +++ b/crates/ghostty-input/src/paste.rs @@ -0,0 +1,225 @@ +//! Paste encoding (ZIG/src/input/paste.zig). + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// Returned if encoding requires a mutable copy of the data. This + /// can only be returned if the input data type is const. + #[error("mutable copy of the data is required")] + MutableRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Options { + /// True if bracketed paste mode is on. + pub bracketed: bool, +} + +impl Options { + // TODO(integration): Zig has `fromTerminal(t: *const Terminal)` which + // reads `t.modes.get(.bracketed_paste)`. Construct `Options` from the + // terminal mode in ghostty-terminal instead. +} + +/// These are the set of byte values that are always replaced by +/// a space (per xterm's behavior) for any text insertion method e.g. +/// a paste, drag and drop, etc. These are copied directly from xterm's +/// source. +const STRIP: &[u8] = &[ + 0x00, // NUL + 0x08, // BS + 0x05, // ENQ + 0x04, // EOT + 0x1B, // ESC + 0x7F, // DEL + // These can be overridden by the running terminal program + // via tcsetattr, so they aren't totally safe to hardcode like + // this. In practice, I haven't seen modern programs change these + // and its a much bigger architectural change to pass these through + // so for now they're hardcoded. + 0x03, // VINTR (Ctrl+C) + 0x1C, // VQUIT (Ctrl+\) + 0x15, // VKILL (Ctrl+U) + 0x1A, // VSUSP (Ctrl+Z) + 0x11, // VSTART (Ctrl+Q) + 0x13, // VSTOP (Ctrl+S) + 0x17, // VWERASE (Ctrl+W) + 0x16, // VLNEXT (Ctrl+V) + 0x12, // VREPRINT (Ctrl+R) + 0x0F, // VDISCARD (Ctrl+O) +]; + +/// Encode immutable (const) data for pasting. The resulting value can be +/// written to the pty to perform a paste of the input data. +/// +/// This returns `Error::MutableRequired` if the data needs modification +/// (strip bytes or newline replacement); the caller can then make a mutable +/// copy and use `encode_mut`. This calling style allows initially const data +/// to be passed in without an allocation, since it is rare in normal use +/// cases that the data will need to be modified. +/// +/// The data is returned as a set of slices to limit allocations. The caller +/// can combine the slices into a single buffer if desired. +/// +/// WARNING: The input data is not checked for safety. See the `is_safe` +/// function to check if the data is safe to paste. +pub fn encode(data: &[u8], opts: Options) -> Result<[&[u8]; 3], Error> { + let mut result: [&[u8]; 3] = [b"", data, b""]; + + if data.iter().any(|b| STRIP.contains(b)) { + return Err(Error::MutableRequired); + } + + // Bracketed paste mode (mode 2004) wraps pasted data in + // fenceposts so that the terminal can ignore things like newlines. + if opts.bracketed { + result[0] = b"\x1b[200~"; + result[2] = b"\x1b[201~"; + return Ok(result); + } + + // Non-bracketed. We have to replace newline with `\r`. + if data.contains(&b'\n') { + return Err(Error::MutableRequired); + } + + Ok(result) +} + +/// Like `encode` but modifies the data in place, so it can't fail. +pub fn encode_mut(data: &mut [u8], opts: Options) -> [&[u8]; 3] { + // If we have any of the strip values, then we need to replace them + // with spaces. This is what xterm does and it does it regardless + // of bracketed paste mode. This is a security measure to prevent pastes + // from containing bytes that could be used to inject commands. + for b in data.iter_mut() { + if STRIP.contains(b) { + *b = b' '; + } + } + + let data: &[u8] = if opts.bracketed { + data + } else { + // Non-bracketed. We have to replace newline with `\r`. This matches + // the behavior of xterm and other terminals. For `\r\n` this will + // result in `\r\r` which does match xterm. + for b in data.iter_mut() { + if *b == b'\n' { + *b = b'\r'; + } + } + data + }; + + let mut result: [&[u8]; 3] = [b"", data, b""]; + if opts.bracketed { + result[0] = b"\x1b[200~"; + result[2] = b"\x1b[201~"; + } + result +} + +/// Returns true if the data looks safe to paste. Data is considered +/// unsafe if it contains any of the following: +/// +/// - `\n`: Newlines can be used to inject commands. +/// - `\x1b[201~`: This is the end of a bracketed paste. This cane be used +/// to exit a bracketed paste and inject commands. +/// +/// We consider any scenario unsafe regardless of current terminal state. +/// For example, even if bracketed paste mode is not active, we still +/// consider `\x1b[201~` unsafe. The existence of these types of bytes +/// should raise suspicion that the producer of the paste data is +/// acting strangely. +pub fn is_safe(data: &[u8]) -> bool { + !data.contains(&b'\n') && !data.windows(6).any(|w| w == b"\x1b[201~") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn is_safe_test() { + assert!(is_safe(b"hello")); + assert!(!is_safe(b"hello\n")); + assert!(!is_safe(b"hello\nworld")); + assert!(!is_safe(b"he\x1b[201~llo")); + } + + #[test] + fn encode_bracketed() { + let r = encode(b"hello", Options { bracketed: true }).unwrap(); + assert_eq!(r[0], b"\x1b[200~"); + assert_eq!(r[1], b"hello"); + assert_eq!(r[2], b"\x1b[201~"); + } + + #[test] + fn encode_unbracketed_no_newlines() { + let r = encode(b"hello", Options { bracketed: false }).unwrap(); + assert_eq!(r[0], b""); + assert_eq!(r[1], b"hello"); + assert_eq!(r[2], b""); + } + + #[test] + fn encode_unbracketed_newlines_const() { + assert_eq!( + encode(b"hello\nworld", Options { bracketed: false }), + Err(Error::MutableRequired) + ); + } + + #[test] + fn encode_unbracketed_newlines() { + let mut data = b"hello\nworld".to_vec(); + let r = encode_mut(&mut data, Options { bracketed: false }); + assert_eq!(r[0], b""); + assert_eq!(r[1], b"hello\rworld"); + assert_eq!(r[2], b""); + } + + #[test] + fn encode_unbracketed_windows_stye_newline() { + let mut data = b"hello\r\nworld".to_vec(); + let r = encode_mut(&mut data, Options { bracketed: false }); + assert_eq!(r[0], b""); + assert_eq!(r[1], b"hello\r\rworld"); + assert_eq!(r[2], b""); + } + + #[test] + fn encode_strip_unsafe_bytes_const() { + assert_eq!( + encode(b"hello\x00world", Options { bracketed: true }), + Err(Error::MutableRequired) + ); + } + + #[test] + fn encode_strip_unsafe_bytes_mutable_bracketed() { + let mut data = b"hel\x1blo\x00world".to_vec(); + let r = encode_mut(&mut data, Options { bracketed: true }); + assert_eq!(r[0], b"\x1b[200~"); + assert_eq!(r[1], b"hel lo world"); + assert_eq!(r[2], b"\x1b[201~"); + } + + #[test] + fn encode_strip_unsafe_bytes_mutable_unbracketed() { + let mut data = b"hel\x03lo".to_vec(); + let r = encode_mut(&mut data, Options { bracketed: false }); + assert_eq!(r[0], b""); + assert_eq!(r[1], b"hel lo"); + assert_eq!(r[2], b""); + } + + #[test] + fn encode_strip_multiple_unsafe_bytes() { + let mut data = b"\x00\x08\x7f".to_vec(); + let r = encode_mut(&mut data, Options { bracketed: true }); + assert_eq!(r[1], b" "); + } +} diff --git a/docs/rewrite/notes/ghostty-input.md b/docs/rewrite/notes/ghostty-input.md new file mode 100644 index 0000000..7950be0 --- /dev/null +++ b/docs/rewrite/notes/ghostty-input.md @@ -0,0 +1,37 @@ +# ghostty-input port notes + +## File mapping (ZIG/src/input -> crates/ghostty-input/src) +- input.zig -> lib.rs (re-exports); config.zig -> config.rs; keyboard.zig -> keyboard.rs +- key.zig -> key.rs (`Key` generated by macro with name()/from_name(); `KeyEvent<'a>` borrows utf8) +- key_mods.zig -> key_mods.rs (`Mods` is a plain bool struct + `int()/from_int()` u16 layout; `RemapSet` is an insertion-ordered Vec) +- keycodes.zig -> keycodes.rs (data table `RAW_ENTRIES`, `entries()` built lazily, Linux = xkb column) +- function_keys.zig -> function_keys.rs; key_encode.zig -> key_encode.rs (legacy + kitty) +- Binding.zig -> binding/{mod,action,set,tests}.rs; command.zig -> command.rs +- helpgen_actions.zig -> helpgen_actions.rs (`generate`/`generate_string`) +- mouse.zig, kitty.zig, paste.zig, Link.zig -> same-named modules +- Skipped: KeymapDarwin.zig, KeymapNoop.zig (macOS only / no-op). + +## Naming deviations +- `Mods.super` -> `super_` (Rust keyword). Zig `final` -> `final_` in kitty::Entry. +- Binding Action doc comments are kept as `//` comments (indented lists would be doctests); text is exposed via `ActionKey::docs()` / `Action::docs()`. +- `Action::scoped(Scope)` returns `Option<&Action>`. +- paste: `encode(&[u8])` (const, may return MutableRequired) and `encode_mut(&mut [u8])` replace the comptime-typed Zig function. +- `Set.chain_parent` is a trigger path instead of raw pointers. +- Case folding: `char::to_lowercase` plus 3 special cases rather than uucode tables. +- `Value::format_entries` fixes a Zig bug (shared-buffer rewind corrupting sibling prefixes for chained leaves in sequences); test `set_format_entries_sequence_shares_prefix`. +- macOS-only branches (option-as-alt translation, ctrlOrSuper on Darwin) are compiled out; Linux behaviour only. + +## Skipped tests +- 7 macOS-only key_encode tests (kitty/legacy with macos option/super text, `alt+x macos`, etc.) and Mods `translation macos-option-as-alt` (ported as a Linux identity test). +- RemapSet `formatEntry` tests use `format_entries()` (list of values) since the config formatter lives in ghostty-config. +- Zig `imguiKey` (cimgui) not ported; inspector deferred. +- `Link.oniRegex` not ported (regex engine chosen by consumer). + +## TODO(integration) +- `paste::Options::fromTerminal`, `key_encode::Options` fromTerminal: build from terminal modes in ghostty-terminal. +- `binding::set::EntryFormatter` trait stands in for config/formatter.zig; config crate should implement it. +- `Command::C` / `defaultsC` are plain structs, not C ABI. +- function_keys test uses a hardcoded 38-byte limit (Zig reads from termio). + +## Status +220 tests pass; clippy -D warnings and fmt clean.