From 11d7a305989b98d2e43045ead7ae6b85af5a7733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 14:59:59 -0400 Subject: [PATCH] T08: Tab and F6 pass Ely's focus keys; each kitty flag reshapes only what it names; Cmd is Super; repeats and Command key-ups come from AppKit; IME commits stay UTF-8, as in kitty and Ghostty; key.zig out; the line rule reads every text file --- AGENTS.md | 10 +- Cargo.lock | 1 + Cargo.toml | 1 + TASKS.md | 2 +- examples/shot.rs | 64 ++- key.zig | 876 ---------------------------------------- scripts/check.sh | 2 +- src/app.rs | 5 +- src/term/input.rs | 97 +++-- src/term/keys.rs | 318 ++++++--------- src/term/keys/legacy.rs | 151 +++++++ src/term/keys/tests.rs | 63 +++ src/term/mac.rs | 58 ++- src/term/view.rs | 4 +- 14 files changed, 505 insertions(+), 1147 deletions(-) delete mode 100644 key.zig create mode 100644 src/term/keys/legacy.rs diff --git a/AGENTS.md b/AGENTS.md index 43011b7..af7b2b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; ## Commands - `cargo run` opens the app. `RUST_LOG=elyterminal=debug cargo run` logs more; `trace` logs each paint. -- `cargo run --example shot -- [--keys "a ctrl-c lopt-f"] [args…]` runs a program in the main window, types the keys as real AppKit events (`lopt`/`ropt` are the left and right Option keys), and writes a PNG of the window (macOS; no permission needed for the app's own window). Run it with `HOME` pointing at a temp folder to try settings without touching your own. +- `cargo run --example shot -- [--keys "a ctrl-c lopt-f repeat-up mark:é commit:é"] [args…]` runs a program in the main window, types the keys as real AppKit events (`lopt`/`ropt` are the left and right Option keys, `repeat-` sets AppKit's repeat flag, `mark:` and `commit:` hand text to the window's input client as an input method does), and writes a PNG of the window (macOS; no permission needed for the app's own window). Run it with `HOME` pointing at a temp folder to try settings without touching your own. - `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule. ## Layout @@ -20,7 +20,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. - `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`. - `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance. -- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, xterm's legacy encoding and the kitty protocol, with no gpui types; `input` is the view's key listeners, its Tab bindings and its IME handler; `mac` reads which Option key is down and keeps held keys repeating. +- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. - `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title. - `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks. - `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them). @@ -44,7 +44,9 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: bash integration loads through `--rcfile`, not Ghostty's `--posix` and `ENV`: macOS's bash 3.2 ignores `ENV` there. bash then runs as a non-login shell; on macOS the script reads the login files itself, so `logout` and `~/.bash_logout` do not apply. - 2026-10-14: icons fall back by hand: a private-use character the font lacks paints in JetBrains Mono NF Mono. gpui's macOS fallbacks go through CoreText's cascade list, which never sees fonts added in memory, and gpui loads no font without an `m`, so the symbols-only Nerd Font cannot load. - 2026-10-14: box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) follow Ghostty's sprite rules (MIT, `LICENSE-GHOSTTY`): whole device pixels, line thickness the underline's, each cell clipped to itself, so lines join across cells whatever the font. A character's pieces never overlap: gpui blends every quad and every path triangle on its own, so a dim line painted twice brightens where it crosses. A drawn base keeps its geometry under combining marks, which paint over a no-break space. -- 2026-10-14: keys follow Ghostty's encoder (MIT). Keys the program reads go out from the key listener; plain text falls through to the input handler, so IME and dead keys compose. Ctrl+[, Ctrl+I and Ctrl+M go as fixterms CSI u, as in Ghostty. Keypad keys always type their characters: Ghostty keeps mode 1035 on, and gpui names keypad keys as main keys on every platform. Tab and Shift-Tab are bound under the `Terminal` context, since Ely binds them to focus moves and bindings run before listeners. +- 2026-10-14: keys follow Ghostty's encoder (MIT). Keys the program reads go out from the key listener; plain text falls through to the input handler, so IME and dead keys compose. Ctrl+[, Ctrl+I and Ctrl+M go as fixterms CSI u, as in Ghostty. Keypad keys always type their characters: Ghostty keeps mode 1035 on, and gpui names keypad keys as main keys on every platform. Ely binds Tab, Shift-Tab, F6 and Shift-F6 to focus moves, and bindings run before listeners; `NoAction` bindings under the `Terminal` context hide them, so these keys reach the listeners with press, repeat and release. +- 2026-10-14: each kitty flag reshapes only what it names, as kitty's encoder does: functional keys keep legacy bytes until disambiguate, events or all is set, and text keys type until all is set. Cmd is kitty's Super, bit 8; a Cmd combo no binding takes reaches the program. +- 2026-10-14: an input method's commit goes to the program as UTF-8 under every kitty flag, as kitty (`keys.c`, `GLFW_IME_COMMIT_TEXT`) and Ghostty (test `kitty: composed text with report all`) send it. - 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it), and shifted punctuation reports the character it types as its key, since gpui clears Shift for it. -- 2026-10-14: on macOS, which Option key is down comes from AppKit's current event, and `ApplePressAndHoldEnabled` is registered off, as Ghostty does, so held keys repeat instead of opening the accent menu. +- 2026-10-14: on macOS, which Option key is down and whether a key repeats come from AppKit's current event: gpui rebuilds keys AppKit routes through its commands as fresh presses. As in Ghostty, `ApplePressAndHoldEnabled` is registered off, so held keys repeat instead of opening the accent menu, and a local event monitor hands Command key-ups to the focused view, since AppKit never routes them to the window. - 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten. diff --git a/Cargo.lock b/Cargo.lock index 0a0e46c..150f768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1750,6 +1750,7 @@ version = "0.1.0" dependencies = [ "alacritty_terminal", "anyhow", + "block", "core-graphics 0.24.0", "dirs 7.0.0", "ely-gpui-component", diff --git a/Cargo.toml b/Cargo.toml index 8dd0398..d375224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ rustix-openpty = "0.2" signal-hook = "0.4" [target.'cfg(target_os = "macos")'.dependencies] +block = "0.1" objc = "0.2" [target.'cfg(target_os = "macos")'.dev-dependencies] diff --git a/TASKS.md b/TASKS.md index 6415728..8438021 100644 --- a/TASKS.md +++ b/TASKS.md @@ -55,7 +55,7 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co ## 12. Menus and shortcuts -- [ ] T22 macOS menu bar; key bindings on all platforms. +- [ ] T22 macOS menu bar; key bindings on all platforms; a key whose press a binding takes sends no release, as in kitty. ## 13. Acceptance diff --git a/examples/shot.rs b/examples/shot.rs index 6a4c0d3..3707722 100644 --- a/examples/shot.rs +++ b/examples/shot.rs @@ -1,4 +1,4 @@ -//! `cargo run --example shot -- [--keys "a ctrl-c lopt-f"] [args…]`: runs a program in the main window, types the keys as AppKit events, then photographs the window. +//! `cargo run --example shot -- [--keys "a ctrl-c lopt-f repeat-up mark:é commit:é"] [args…]`: runs a program in the main window, types the keys as AppKit events, then photographs the window. #[cfg(target_os = "macos")] fn main() { @@ -120,12 +120,12 @@ mod typing { use core_graphics::geometry::CGPoint; use objc::{ class, msg_send, - runtime::{NO, Object}, + runtime::{NO, Object, YES}, sel, sel_impl, }; /// US layout key codes and what each key types. - const KEYS: [(&str, u16, &str); 17] = [ + const KEYS: [(&str, u16, &str); 18] = [ ("a", 0, "a"), ("c", 8, "c"), ("e", 14, "e"), @@ -143,10 +143,55 @@ mod typing { ("left", 123, "\u{F702}"), ("f1", 122, "\u{F704}"), ("f5", 96, "\u{F708}"), + ("f6", 97, "\u{F709}"), ]; - /// Posts `spec`, such as `ctrl-c` or `lopt-f`, as a key going down and up on window `number`. + /// AppKit's `NSRange`. + #[repr(C)] + struct Range { + location: u64, + length: u64, + } + + fn string(text: &str) -> *mut Object { + let text = std::ffi::CString::new(text).expect("no NUL in key text"); + unsafe { msg_send![class!(NSString), stringWithUTF8String: text.as_ptr()] } + } + + /// `mark` composes `text` as an input method does; `commit` types it. + fn compose(call: &str, text: &str, number: u32) -> anyhow::Result<()> { + let none = Range { + location: i64::MAX as u64, + length: 0, + }; + unsafe { + let app: *mut Object = msg_send![class!(NSApplication), sharedApplication]; + let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)]; + anyhow::ensure!(!window.is_null(), "no window {number}"); + let client: *mut Object = msg_send![window, firstResponder]; + match call { + "mark" => { + let end = Range { + location: text.encode_utf16().count() as u64, + length: 0, + }; + let _: () = msg_send![client, setMarkedText: string(text) selectedRange: end replacementRange: none]; + } + "commit" => { + let _: () = msg_send![client, insertText: string(text) replacementRange: none]; + } + other => anyhow::bail!("no input method call {other}"), + } + } + log::info!("shot: {call} {text:?}"); + Ok(()) + } + + /// Posts `spec`, such as `ctrl-c`, `lopt-f` or `repeat-up`, as a key going down and up on window `number`. pub fn post(spec: &str, number: u32) -> anyhow::Result<()> { + if let Some((call, text)) = spec.split_once(':') { + return compose(call, text, number); + } let mut parts: Vec<&str> = spec.split('-').collect(); let name = parts.pop().context("an empty key")?; let (_, code, plain) = KEYS @@ -154,8 +199,13 @@ mod typing { .find(|(key, ..)| *key == name) .with_context(|| format!("no key code for {name}"))?; let mut flags: u64 = 0; + let mut repeat = NO; for modifier in &parts { flags |= match *modifier { + "repeat" => { + repeat = YES; + 0 + } "shift" => 0x20000 | 0x02, "ctrl" => 0x40000 | 0x01, "lopt" => 0x80000 | 0x20, @@ -185,10 +235,6 @@ mod typing { }; for kind in [10u64, 11] { unsafe { - let string = |text: &str| -> *mut Object { - let text = std::ffi::CString::new(text).expect("no NUL in key text"); - msg_send![class!(NSString), stringWithUTF8String: text.as_ptr()] - }; let event: *mut Object = msg_send![ class!(NSEvent), keyEventWithType: kind @@ -199,7 +245,7 @@ mod typing { context: std::ptr::null_mut::() characters: string(&typed) charactersIgnoringModifiers: string(&ignoring) - isARepeat: NO + isARepeat: if kind == 10 { repeat } else { NO } keyCode: *code ]; anyhow::ensure!(!event.is_null(), "AppKit refused the key {spec}"); diff --git a/key.zig b/key.zig deleted file mode 100644 index 1bf6491..0000000 --- a/key.zig +++ /dev/null @@ -1,876 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const Allocator = std.mem.Allocator; -const cimgui = @import("dcimgui"); - -pub const Mods = @import("key_mods.zig").Mods; - -/// A generic key input event. This is the information that is necessary -/// regardless of apprt in order to generate the proper terminal -/// control sequences for a given key press. -/// -/// Some apprts may not be able to provide all of this information, such -/// as GLFW. In this case, the apprt should provide as much information -/// as it can and it should be expected that the terminal behavior -/// will not be totally correct. -pub const KeyEvent = struct { - /// The action: press, release, etc. - action: Action = .press, - - /// The keycode of the physical key that was pressed. This is agnostic - /// to the layout. Layout-dependent matching can only be done via the - /// UTF-8 or unshifted codepoint. - key: Key = .unidentified, - - /// Mods are the modifiers that are pressed. - mods: Mods = .{}, - - /// The mods that were consumed in order to generate the text - /// in utf8. This has the mods set that were consumed, so to - /// get the set of mods that are effective you must negate - /// mods with this. - /// - /// This field is meaningless if utf8 is empty. - consumed_mods: Mods = .{}, - - /// Composing is true when this key event is part of a dead key - /// composition sequence and we're in the middle of it. - composing: bool = false, - - /// The utf8 sequence that was generated by this key event. - /// This will be an empty string if there is no text generated. - /// If composing is true and this is non-empty, this is preedit - /// text. - utf8: []const u8 = "", - - /// The codepoint for this key when it is unshifted. For example, - /// shift+a is "A" in UTF-8 but unshifted would provide 'a'. - unshifted_codepoint: u21 = 0, - - /// Returns the effective modifiers for this event. The effective - /// modifiers are the mods that should be considered for keybindings. - pub fn effectiveMods(self: KeyEvent) Mods { - if (self.utf8.len == 0) return self.mods; - return self.mods.unset(self.consumed_mods); - } - - /// Returns a unique hash for this key event to be used for tracking - /// uniquess specifically with bindings. This omits fields that are - /// irrelevant for bindings. - pub fn bindingHash(self: KeyEvent) u64 { - var hasher = std.hash.Wyhash.init(0); - - // These are all the fields that are explicitly part of Trigger. - std.hash.autoHash(&hasher, self.key); - std.hash.autoHash(&hasher, self.unshifted_codepoint); - std.hash.autoHash(&hasher, self.mods.binding()); - - // Notes on unmapped things and why: - // - // - action: we don't have action-specific bindings right now - // AND we want to know if a key resulted in a binding regardless - // of action because a press should also ignore a release and so on. - // - // We can add to this if there is other confusion. - - return hasher.final(); - } -}; - -/// The action associated with an input event. This is backed by a c_int -/// so that we can use the enum as-is for our embedding API. -/// -/// IMPORTANT: Any changes here update include/ghostty.h -pub const Action = enum(c_int) { - release, - press, - repeat, -}; - -/// The set of key codes that Ghostty is aware of. These represent -/// physical keys on the keyboard. The logical key (or key string) -/// is the string that is generated by the key event and that is up -/// to the apprt to provide. -/// -/// Note that these are layout-independent. For example, the "a" -/// key on a US keyboard is the same as the "ф" key on a Russian -/// keyboard, but both will report the "a" enum value in the key -/// event. These values are based on the W3C standard. See: -/// https://www.w3.org/TR/uievents-code -/// -/// Layout-dependent strings are provided in the KeyEvent struct as -/// UTF-8 and are produced by the associated apprt. Ghostty core has -/// no mechanism to map input events to strings without the apprt. -/// -/// IMPORTANT: Any changes here update include/ghostty.h ghostty_input_key_e -pub const Key = enum(c_int) { - unidentified, - - // "Writing System Keys" § 3.1.1 - backquote, - backslash, - bracket_left, - bracket_right, - comma, - digit_0, - digit_1, - digit_2, - digit_3, - digit_4, - digit_5, - digit_6, - digit_7, - digit_8, - digit_9, - equal, - intl_backslash, - intl_ro, - intl_yen, - key_a, - key_b, - key_c, - key_d, - key_e, - key_f, - key_g, - key_h, - key_i, - key_j, - key_k, - key_l, - key_m, - key_n, - key_o, - key_p, - key_q, - key_r, - key_s, - key_t, - key_u, - key_v, - key_w, - key_x, - key_y, - key_z, - minus, - period, - quote, - semicolon, - slash, - - // "Functional Keys" § 3.1.2 - alt_left, - alt_right, - backspace, - caps_lock, - context_menu, - control_left, - control_right, - enter, - meta_left, - meta_right, - shift_left, - shift_right, - space, - tab, - convert, - kana_mode, - non_convert, - - // "Control Pad Section" § 3.2 - delete, - end, - help, - home, - insert, - page_down, - page_up, - - // "Arrow Pad Section" § 3.3 - arrow_down, - arrow_left, - arrow_right, - arrow_up, - - // "Numpad Section" § 3.4 - num_lock, - numpad_0, - numpad_1, - numpad_2, - numpad_3, - numpad_4, - numpad_5, - numpad_6, - numpad_7, - numpad_8, - numpad_9, - numpad_add, - numpad_backspace, - numpad_clear, - numpad_clear_entry, - numpad_comma, - numpad_decimal, - numpad_divide, - numpad_enter, - numpad_equal, - numpad_memory_add, - numpad_memory_clear, - numpad_memory_recall, - numpad_memory_store, - numpad_memory_subtract, - numpad_multiply, - numpad_paren_left, - numpad_paren_right, - numpad_subtract, - - // > For numpads that provide keys not listed here, a code value string - // > should be created by starting with "Numpad" and appending an - // > appropriate description of the key. - // - // These numpad entries are distinguished by various encoding protocols - // (legacy and Kitty) so we support them here in case the apprt can - // produce them. - numpad_separator, - numpad_up, - numpad_down, - numpad_right, - numpad_left, - numpad_begin, - numpad_home, - numpad_end, - numpad_insert, - numpad_delete, - numpad_page_up, - numpad_page_down, - - // "Function Section" § 3.5 - escape, - f1, - f2, - f3, - f4, - f5, - f6, - f7, - f8, - f9, - f10, - f11, - f12, - f13, - f14, - f15, - f16, - f17, - f18, - f19, - f20, - f21, - f22, - f23, - f24, - f25, - @"fn", - fn_lock, - print_screen, - scroll_lock, - pause, - - // "Media Keys" § 3.6 - browser_back, - browser_favorites, - browser_forward, - browser_home, - browser_refresh, - browser_search, - browser_stop, - eject, - launch_app_1, - launch_app_2, - launch_mail, - media_play_pause, - media_select, - media_stop, - media_track_next, - media_track_previous, - power, - sleep, - audio_volume_down, - audio_volume_mute, - audio_volume_up, - wake_up, - - // "Legacy, Non-standard, and Special Keys" § 3.7 - copy, - cut, - paste, - - /// Converts an ASCII character to a key, if possible. This returns - /// null if the character is unknown. - /// - /// Note that this can't distinguish between physical keys, i.e. '0' - /// may be from the number row or the keypad, but it always maps - /// to '.zero'. - /// - /// This is what we want, we want people to create keybindings that - /// are independent of the physical key. - pub fn fromASCII(ch: u8) ?Key { - return switch (ch) { - inline else => |comptime_ch| { - return comptime result: { - @setEvalBranchQuota(100_000); - for (codepoint_map) |entry| { - // No ASCII characters should ever map to a keypad key - if (entry[1].keypad()) continue; - - if (entry[0] == @as(u21, @intCast(comptime_ch))) { - break :result entry[1]; - } - } - - break :result null; - }; - }, - }; - } - - /// Converts a W3C key code to a Ghostty key enum value. - /// - /// All required W3C key codes are supported, but there are a number of - /// non-standard key codes that are not supported. In the case the value is - /// invalid or unsupported, this function will return null. - pub fn fromW3C(code: []const u8) ?Key { - var result: [128]u8 = undefined; - - // If the code is bigger than our buffer it can't possibly match. - if (code.len > result.len) return null; - - // First just check the whole thing lowercased, this is the simple case - if (std.meta.stringToEnum( - Key, - std.ascii.lowerString(&result, code), - )) |key| return key; - - // We need to convert FooBar to foo_bar - var w: std.Io.Writer = .fixed(&result); - for (code, 0..) |ch, i| switch (ch) { - 'a'...'z' => w.writeByte(ch) catch return null, - - // Caps and numbers trigger underscores - 'A'...'Z', '0'...'9' => { - if (i > 0) w.writeByte('_') catch return null; - w.writeByte(std.ascii.toLower(ch)) catch return null; - }, - - // We don't know of any key codes that aren't alphanumeric. - else => return null, - }; - - return std.meta.stringToEnum(Key, w.buffered()); - } - - /// Converts a Ghostty key enum value to a W3C key code. - pub fn w3c(self: Key) []const u8 { - return switch (self) { - inline else => |tag| comptime w3c: { - @setEvalBranchQuota(50_000); - - const name = @tagName(tag); - - var buf: [128]u8 = undefined; - var w: std.Io.Writer = .fixed(&buf); - var i: usize = 0; - while (i < name.len) { - if (i == 0) { - w.writeByte(std.ascii.toUpper(name[i])) catch unreachable; - } else if (name[i] == '_') { - i += 1; - w.writeByte(std.ascii.toUpper(name[i])) catch unreachable; - } else { - w.writeByte(name[i]) catch unreachable; - } - - i += 1; - } - - const written = buf; - const result = written[0..w.end]; - break :w3c result; - }, - }; - } - - /// True if this key represents a printable character. - pub fn printable(self: Key) bool { - return switch (self) { - inline else => |tag| { - return comptime result: { - @setEvalBranchQuota(10_000); - for (codepoint_map) |entry| { - if (entry[1] == tag) break :result true; - } - - break :result false; - }; - }, - }; - } - - /// True if this key is a modifier. - pub fn modifier(self: Key) bool { - return switch (self) { - .shift_left, - .control_left, - .alt_left, - .meta_left, - .shift_right, - .control_right, - .alt_right, - .meta_right, - => true, - - else => false, - }; - } - - /// Whether this key should be remappable by the operating system. - /// - /// On certain OSes (namely Linux and the BSDs) certain keys like the - /// functional keys are expected to be remappable by the user, such as - /// in the very common use case of swapping the Caps Lock key with the - /// Escape key with the XKB option `caps:swapescape`. - /// - /// However, the way XKB implements this is by essentially acting as a - /// software key remapper that destroys all information about the original - /// physical key, leading to very annoying bugs like #7309 where the - /// physical key `XKB_KEY_c` gets remapped into `XKB_KEY_Cyrillic_tse`, - /// which causes all of our physical key handling to completely break down. - /// _Very naughty._ - /// - /// As a compromise, given that writing system keys (§3.1.1) comprise the - /// majority of keys that "change meaning [...] based on the current locale - /// and keyboard layout", we allow all other keys to be remapped by default - /// since they should be fairly harmless. We might consider making this - /// configurable, but for now this should at least placate most people. - pub fn shouldBeRemappable(self: Key) bool { - return switch (self) { - // "Writing System Keys" § 3.1.1 - .backquote, - .backslash, - .bracket_left, - .bracket_right, - .comma, - .digit_0, - .digit_1, - .digit_2, - .digit_3, - .digit_4, - .digit_5, - .digit_6, - .digit_7, - .digit_8, - .digit_9, - .equal, - .intl_backslash, - .intl_ro, - .intl_yen, - .key_a, - .key_b, - .key_c, - .key_d, - .key_e, - .key_f, - .key_g, - .key_h, - .key_i, - .key_j, - .key_k, - .key_l, - .key_m, - .key_n, - .key_o, - .key_p, - .key_q, - .key_r, - .key_s, - .key_t, - .key_u, - .key_v, - .key_w, - .key_x, - .key_y, - .key_z, - .minus, - .period, - .quote, - .semicolon, - .slash, - => false, - - else => true, - }; - } - - /// Returns true if this is a keypad key. - pub fn keypad(self: Key) bool { - return switch (self) { - inline else => |tag| { - const name = @tagName(tag); - const result = comptime std.mem.startsWith(u8, name, "numpad_"); - return result; - }, - }; - } - - // Returns the codepoint representing this key, or null if the key is not - // printable - pub fn codepoint(self: Key) ?u21 { - return switch (self) { - inline else => |tag| { - return comptime result: { - @setEvalBranchQuota(10_000); - for (codepoint_map) |entry| { - if (entry[1] == tag) break :result entry[0]; - } - - break :result null; - }; - }, - }; - } - - /// Returns the cimgui key constant for this key. - pub fn imguiKey(self: Key) ?c_int { - return switch (self) { - .key_a => cimgui.c.ImGuiKey_A, - .key_b => cimgui.c.ImGuiKey_B, - .key_c => cimgui.c.ImGuiKey_C, - .key_d => cimgui.c.ImGuiKey_D, - .key_e => cimgui.c.ImGuiKey_E, - .key_f => cimgui.c.ImGuiKey_F, - .key_g => cimgui.c.ImGuiKey_G, - .key_h => cimgui.c.ImGuiKey_H, - .key_i => cimgui.c.ImGuiKey_I, - .key_j => cimgui.c.ImGuiKey_J, - .key_k => cimgui.c.ImGuiKey_K, - .key_l => cimgui.c.ImGuiKey_L, - .key_m => cimgui.c.ImGuiKey_M, - .key_n => cimgui.c.ImGuiKey_N, - .key_o => cimgui.c.ImGuiKey_O, - .key_p => cimgui.c.ImGuiKey_P, - .key_q => cimgui.c.ImGuiKey_Q, - .key_r => cimgui.c.ImGuiKey_R, - .key_s => cimgui.c.ImGuiKey_S, - .key_t => cimgui.c.ImGuiKey_T, - .key_u => cimgui.c.ImGuiKey_U, - .key_v => cimgui.c.ImGuiKey_V, - .key_w => cimgui.c.ImGuiKey_W, - .key_x => cimgui.c.ImGuiKey_X, - .key_y => cimgui.c.ImGuiKey_Y, - .key_z => cimgui.c.ImGuiKey_Z, - - .digit_0 => cimgui.c.ImGuiKey_0, - .digit_1 => cimgui.c.ImGuiKey_1, - .digit_2 => cimgui.c.ImGuiKey_2, - .digit_3 => cimgui.c.ImGuiKey_3, - .digit_4 => cimgui.c.ImGuiKey_4, - .digit_5 => cimgui.c.ImGuiKey_5, - .digit_6 => cimgui.c.ImGuiKey_6, - .digit_7 => cimgui.c.ImGuiKey_7, - .digit_8 => cimgui.c.ImGuiKey_8, - .digit_9 => cimgui.c.ImGuiKey_9, - - .semicolon => cimgui.c.ImGuiKey_Semicolon, - .space => cimgui.c.ImGuiKey_Space, - .quote => cimgui.c.ImGuiKey_Apostrophe, - .comma => cimgui.c.ImGuiKey_Comma, - .backquote => cimgui.c.ImGuiKey_GraveAccent, - .period => cimgui.c.ImGuiKey_Period, - .slash => cimgui.c.ImGuiKey_Slash, - .minus => cimgui.c.ImGuiKey_Minus, - .equal => cimgui.c.ImGuiKey_Equal, - .bracket_left => cimgui.c.ImGuiKey_LeftBracket, - .bracket_right => cimgui.c.ImGuiKey_RightBracket, - .backslash => cimgui.c.ImGuiKey_Backslash, - - .arrow_up => cimgui.c.ImGuiKey_UpArrow, - .arrow_down => cimgui.c.ImGuiKey_DownArrow, - .arrow_left => cimgui.c.ImGuiKey_LeftArrow, - .arrow_right => cimgui.c.ImGuiKey_RightArrow, - .home => cimgui.c.ImGuiKey_Home, - .end => cimgui.c.ImGuiKey_End, - .insert => cimgui.c.ImGuiKey_Insert, - .delete => cimgui.c.ImGuiKey_Delete, - .caps_lock => cimgui.c.ImGuiKey_CapsLock, - .scroll_lock => cimgui.c.ImGuiKey_ScrollLock, - .num_lock => cimgui.c.ImGuiKey_NumLock, - .page_up => cimgui.c.ImGuiKey_PageUp, - .page_down => cimgui.c.ImGuiKey_PageDown, - .escape => cimgui.c.ImGuiKey_Escape, - .enter => cimgui.c.ImGuiKey_Enter, - .tab => cimgui.c.ImGuiKey_Tab, - .backspace => cimgui.c.ImGuiKey_Backspace, - .print_screen => cimgui.c.ImGuiKey_PrintScreen, - .pause => cimgui.c.ImGuiKey_Pause, - .context_menu => cimgui.c.ImGuiKey_Menu, - - .f1 => cimgui.c.ImGuiKey_F1, - .f2 => cimgui.c.ImGuiKey_F2, - .f3 => cimgui.c.ImGuiKey_F3, - .f4 => cimgui.c.ImGuiKey_F4, - .f5 => cimgui.c.ImGuiKey_F5, - .f6 => cimgui.c.ImGuiKey_F6, - .f7 => cimgui.c.ImGuiKey_F7, - .f8 => cimgui.c.ImGuiKey_F8, - .f9 => cimgui.c.ImGuiKey_F9, - .f10 => cimgui.c.ImGuiKey_F10, - .f11 => cimgui.c.ImGuiKey_F11, - .f12 => cimgui.c.ImGuiKey_F12, - - .numpad_0 => cimgui.c.ImGuiKey_Keypad0, - .numpad_1 => cimgui.c.ImGuiKey_Keypad1, - .numpad_2 => cimgui.c.ImGuiKey_Keypad2, - .numpad_3 => cimgui.c.ImGuiKey_Keypad3, - .numpad_4 => cimgui.c.ImGuiKey_Keypad4, - .numpad_5 => cimgui.c.ImGuiKey_Keypad5, - .numpad_6 => cimgui.c.ImGuiKey_Keypad6, - .numpad_7 => cimgui.c.ImGuiKey_Keypad7, - .numpad_8 => cimgui.c.ImGuiKey_Keypad8, - .numpad_9 => cimgui.c.ImGuiKey_Keypad9, - .numpad_decimal => cimgui.c.ImGuiKey_KeypadDecimal, - .numpad_divide => cimgui.c.ImGuiKey_KeypadDivide, - .numpad_multiply => cimgui.c.ImGuiKey_KeypadMultiply, - .numpad_subtract => cimgui.c.ImGuiKey_KeypadSubtract, - .numpad_add => cimgui.c.ImGuiKey_KeypadAdd, - .numpad_enter => cimgui.c.ImGuiKey_KeypadEnter, - .numpad_equal => cimgui.c.ImGuiKey_KeypadEqual, - // We map KP_SEPARATOR to Comma because traditionally a numpad would - // have a numeric separator key. Most modern numpads do not - .numpad_left => cimgui.c.ImGuiKey_LeftArrow, - .numpad_right => cimgui.c.ImGuiKey_RightArrow, - .numpad_up => cimgui.c.ImGuiKey_UpArrow, - .numpad_down => cimgui.c.ImGuiKey_DownArrow, - .numpad_page_up => cimgui.c.ImGuiKey_PageUp, - .numpad_page_down => cimgui.c.ImGuiKey_PageUp, - .numpad_home => cimgui.c.ImGuiKey_Home, - .numpad_end => cimgui.c.ImGuiKey_End, - .numpad_insert => cimgui.c.ImGuiKey_Insert, - .numpad_delete => cimgui.c.ImGuiKey_Delete, - .numpad_begin => cimgui.c.ImGuiKey_NamedKey_BEGIN, - - .shift_left => cimgui.c.ImGuiKey_LeftShift, - .control_left => cimgui.c.ImGuiKey_LeftCtrl, - .alt_left => cimgui.c.ImGuiKey_LeftAlt, - .meta_left => cimgui.c.ImGuiKey_LeftSuper, - .shift_right => cimgui.c.ImGuiKey_RightShift, - .control_right => cimgui.c.ImGuiKey_RightCtrl, - .alt_right => cimgui.c.ImGuiKey_RightAlt, - .meta_right => cimgui.c.ImGuiKey_RightSuper, - - // These keys aren't represented in cimgui - .f13, - .f14, - .f15, - .f16, - .f17, - .f18, - .f19, - .f20, - .f21, - .f22, - .f23, - .f24, - .f25, - .intl_backslash, - .intl_ro, - .intl_yen, - .convert, - .kana_mode, - .non_convert, - .numpad_separator, - .numpad_backspace, - .numpad_clear, - .numpad_clear_entry, - .numpad_comma, - .numpad_memory_add, - .numpad_memory_clear, - .numpad_memory_recall, - .numpad_memory_store, - .numpad_memory_subtract, - .numpad_paren_left, - .numpad_paren_right, - .@"fn", - .fn_lock, - .browser_back, - .browser_favorites, - .browser_forward, - .browser_home, - .browser_refresh, - .browser_search, - .browser_stop, - .eject, - .launch_app_1, - .launch_app_2, - .launch_mail, - .media_play_pause, - .media_select, - .media_stop, - .media_track_next, - .media_track_previous, - .power, - .sleep, - .audio_volume_down, - .audio_volume_mute, - .audio_volume_up, - .wake_up, - .help, - .copy, - .cut, - .paste, - => null, - - .unidentified, - => null, - }; - } - - /// true if this key is one of the left or right versions of super (MacOS) - /// or ctrl. - pub fn ctrlOrSuper(self: Key) bool { - if (comptime builtin.target.os.tag.isDarwin()) { - return self == .meta_left or self == .meta_right; - } - return self == .control_left or self == .control_right; - } - - /// true if this key is either left or right shift. - pub fn leftOrRightShift(self: Key) bool { - return self == .shift_left or self == .shift_right; - } - - /// true if this key is either left or right alt. - pub fn leftOrRightAlt(self: Key) bool { - return self == .alt_left or self == .alt_right; - } - - test "fromASCII should not return keypad keys" { - const testing = std.testing; - try testing.expect(Key.fromASCII('0').? == .digit_0); - try testing.expect(Key.fromASCII('*') == null); - } - - test "keypad keys" { - const testing = std.testing; - try testing.expect(Key.numpad_0.keypad()); - try testing.expect(!Key.digit_1.keypad()); - } - - test "w3c" { - // All our keys should convert to and from the W3C format. - // We don't support every key in the W3C spec, so we only - // check the enum fields. - const testing = std.testing; - inline for (@typeInfo(Key).@"enum".fields) |field| { - const key = @field(Key, field.name); - const w3c_name = key.w3c(); - try testing.expectEqual(key, Key.fromW3C(w3c_name).?); - } - } - - const codepoint_map: []const struct { u21, Key } = &.{ - .{ 'a', .key_a }, - .{ 'b', .key_b }, - .{ 'c', .key_c }, - .{ 'd', .key_d }, - .{ 'e', .key_e }, - .{ 'f', .key_f }, - .{ 'g', .key_g }, - .{ 'h', .key_h }, - .{ 'i', .key_i }, - .{ 'j', .key_j }, - .{ 'k', .key_k }, - .{ 'l', .key_l }, - .{ 'm', .key_m }, - .{ 'n', .key_n }, - .{ 'o', .key_o }, - .{ 'p', .key_p }, - .{ 'q', .key_q }, - .{ 'r', .key_r }, - .{ 's', .key_s }, - .{ 't', .key_t }, - .{ 'u', .key_u }, - .{ 'v', .key_v }, - .{ 'w', .key_w }, - .{ 'x', .key_x }, - .{ 'y', .key_y }, - .{ 'z', .key_z }, - .{ '0', .digit_0 }, - .{ '1', .digit_1 }, - .{ '2', .digit_2 }, - .{ '3', .digit_3 }, - .{ '4', .digit_4 }, - .{ '5', .digit_5 }, - .{ '6', .digit_6 }, - .{ '7', .digit_7 }, - .{ '8', .digit_8 }, - .{ '9', .digit_9 }, - .{ ';', .semicolon }, - .{ ' ', .space }, - .{ '\'', .quote }, - .{ ',', .comma }, - .{ '`', .backquote }, - .{ '.', .period }, - .{ '/', .slash }, - .{ '-', .minus }, - .{ '=', .equal }, - .{ '[', .bracket_left }, - .{ ']', .bracket_right }, - .{ '\\', .backslash }, - - // Control characters - .{ '\t', .tab }, - - // Keypad entries. We just assume keypad with the numpad_ prefix - // so that has some special meaning. These must also always be last, - // so that our `fromASCII` function doesn't accidentally map them - // over normal numerics and other keys. - .{ '0', .numpad_0 }, - .{ '1', .numpad_1 }, - .{ '2', .numpad_2 }, - .{ '3', .numpad_3 }, - .{ '4', .numpad_4 }, - .{ '5', .numpad_5 }, - .{ '6', .numpad_6 }, - .{ '7', .numpad_7 }, - .{ '8', .numpad_8 }, - .{ '9', .numpad_9 }, - .{ '.', .numpad_decimal }, - .{ '/', .numpad_divide }, - .{ '*', .numpad_multiply }, - .{ '-', .numpad_subtract }, - .{ '+', .numpad_add }, - .{ '=', .numpad_equal }, - }; -}; - -/// This sets either "ctrl" or "super" to true (but not both) -/// on mods depending on if the build target is Mac or not. On -/// Mac, we default to super (i.e. super+c for copy) and on -/// non-Mac we default to ctrl (i.e. ctrl+c for copy). -pub fn ctrlOrSuper(mods: Mods) Mods { - var copy = mods; - if (comptime builtin.target.os.tag.isDarwin()) { - copy.super = true; - } else { - copy.ctrl = true; - } - - return copy; -} - -test "ctrlOrSuper" { - const testing = std.testing; - var m: Mods = ctrlOrSuper(.{}); - - try testing.expect(m.ctrlOrSuper()); -} diff --git a/scripts/check.sh b/scripts/check.sh index ea15de2..15c65c4 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -9,7 +9,7 @@ files=$(git ls-files -co --exclude-standard '*.rs') multi=$(awk 'FNR==1{prev=0} /^[[:space:]]*\/\// {if(prev) print FILENAME":"FNR; prev=1; next} {prev=0}' $files) [ -z "$multi" ] || { echo "multi-line comments:"; echo "$multi"; fail=1; } for f in $(git ls-files -co --exclude-standard); do - case "$f" in *.rs|*.md|*.sh|*.zsh|*.bash|*.fish|*.ps1|*.toml|*.yml) ;; *) continue ;; esac + [ "$f" != Cargo.lock ] && grep -Iq '' "$f" || continue n=$(wc -l < "$f") [ "$n" -le 500 ] || { echo "$f has $n lines"; fail=1; } done diff --git a/src/app.rs b/src/app.rs index d5569d6..db5c630 100644 --- a/src/app.rs +++ b/src/app.rs @@ -56,7 +56,10 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> { ShellIntegration::init(cx)?; input::bind_keys(cx); #[cfg(target_os = "macos")] - mac::repeat_held_keys(); + { + mac::repeat_held_keys(); + mac::deliver_command_key_ups()?; + } let family = &SettingsStore::get(cx).font_family; anyhow::ensure!( cx.text_system().all_font_names().contains(family), diff --git a/src/term/input.rs b/src/term/input.rs index e6e79d5..2680f58 100644 --- a/src/term/input.rs +++ b/src/term/input.rs @@ -3,7 +3,7 @@ use std::ops::Range; use alacritty_terminal::{grid::Scroll, term::TermMode}; use gpui::{ App, Bounds, Context, EntityInputHandler, KeyBinding, KeyDownEvent, KeyUpEvent, Keystroke, - Pixels, Point, UTF16Selection, Window, actions, + NoAction, Pixels, Point, UTF16Selection, Window, }; use super::{ @@ -11,17 +11,15 @@ use super::{ view::TerminalView, }; -actions!(terminal, [SendTab, SendBackTab]); - /// The key context a terminal's root sets. pub const CONTEXT: &str = "Terminal"; -/// Tab and Shift-Tab reach the terminal before Ely moves focus with them. +/// Ely's focus keys go to the terminal's listeners instead. pub fn bind_keys(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("tab", SendTab, Some(CONTEXT)), - KeyBinding::new("shift-tab", SendBackTab, Some(CONTEXT)), - ]); + cx.bind_keys( + ["tab", "shift-tab", "f6", "shift-f6"] + .map(|key| KeyBinding::new(key, NoAction, Some(CONTEXT))), + ); } impl TerminalView { @@ -41,41 +39,34 @@ impl TerminalView { if event.prefer_character_input { return; } - let action = if event.is_held { + let facts = self.facts(); + let action = if event.is_held || facts.repeat { Action::Repeat } else { Action::Press }; - if self.send(&event.keystroke, action, cx) { + if self.send(&event.keystroke, action, facts, cx) { cx.stop_propagation(); } } pub(super) fn key_up(&mut self, event: &KeyUpEvent, _: &mut Window, cx: &mut Context) { - if self.send(&event.keystroke, Action::Release, cx) { + let facts = self.facts(); + if self.send(&event.keystroke, Action::Release, facts, cx) { cx.stop_propagation(); } } - pub(super) fn tab(&mut self, shift: bool, cx: &mut Context) { - let key = Key { - name: "tab", - text: if shift { "" } else { "\t" }, - shift, - ctrl: false, - alt: false, - action: Action::Press, - }; - self.encode(&key, cx); - } - /// Sends what `keystroke` means to the program; false when it is text the input method types. - fn send(&mut self, keystroke: &Keystroke, action: Action, cx: &mut Context) -> bool { + fn send( + &mut self, + keystroke: &Keystroke, + action: Action, + facts: Facts, + cx: &mut Context, + ) -> bool { let modifiers = keystroke.modifiers; - if modifiers.platform { - return false; - } - let alt = modifiers.alt && self.alt_is_meta(cx); + let alt = modifiers.alt && Self::alt_is_meta(facts, cx); let text = match &keystroke.key_char { Some(_) if alt && cfg!(target_os = "macos") => "", Some(text) => text.as_str(), @@ -87,12 +78,9 @@ impl TerminalView { shift: modifiers.shift, ctrl: modifiers.control, alt, + platform: modifiers.platform, action, }; - self.encode(&key, cx) - } - - fn encode(&mut self, key: &Key, cx: &mut Context) -> bool { let mode = *self.pty.term.lock().mode(); let kitty = Kitty { disambiguate: mode.contains(TermMode::DISAMBIGUATE_ESC_CODES), @@ -101,7 +89,7 @@ impl TerminalView { all: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC), text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT), }; - let Some(bytes) = keys::encode(key, mode.contains(TermMode::APP_CURSOR), kitty) else { + let Some(bytes) = keys::encode(&key, mode.contains(TermMode::APP_CURSOR), kitty) else { return false; }; log::trace!("terminal {}: {key:?} sends {bytes:?}", self.pty.pid); @@ -109,31 +97,52 @@ impl TerminalView { true } + /// AppKit's facts of the key; logged and none when unreadable. + #[cfg(target_os = "macos")] + fn facts(&self) -> Facts { + super::mac::key_facts().unwrap_or_else(|error| { + log::error!("terminal {}: {error:#}", self.pty.pid); + Facts { + left_option: false, + right_option: false, + repeat: false, + } + }) + } + + #[cfg(not(target_os = "macos"))] + fn facts(&self) -> Facts { + Facts::default() + } + /// Whether a held Alt is a modifier; on macOS only the Option keys the settings name. #[cfg(target_os = "macos")] - fn alt_is_meta(&self, cx: &App) -> bool { + fn alt_is_meta(facts: Facts, cx: &App) -> bool { use crate::settings::{OptionAsAlt, SettingsStore}; - let sides = || match super::mac::option_sides() { - Ok(sides) => Some(sides), - Err(error) => { - log::error!("terminal {}: {error:#}; Option composes", self.pty.pid); - None - } - }; match SettingsStore::get(cx).option_as_alt { OptionAsAlt::Off => false, OptionAsAlt::Both => true, - OptionAsAlt::Left => sides().is_some_and(|(left, _)| left), - OptionAsAlt::Right => sides().is_some_and(|(_, right)| right), + OptionAsAlt::Left => facts.left_option, + OptionAsAlt::Right => facts.right_option, } } #[cfg(not(target_os = "macos"))] - fn alt_is_meta(&self, _: &App) -> bool { + fn alt_is_meta(_: Facts, _: &App) -> bool { true } } +#[cfg(target_os = "macos")] +type Facts = super::mac::KeyFacts; + +/// Other platforms tell gpui all it needs. +#[cfg(not(target_os = "macos"))] +#[derive(Clone, Copy, Debug, Default)] +struct Facts { + repeat: bool, +} + impl EntityInputHandler for TerminalView { fn text_for_range( &mut self, diff --git a/src/term/keys.rs b/src/term/keys.rs index 2326f03..e1d92ab 100644 --- a/src/term/keys.rs +++ b/src/term/keys.rs @@ -1,3 +1,7 @@ +mod legacy; + +use legacy::{FUNCTION_TILDES, legacy, printable_key}; + /// Whether a key went down, repeated, or came up. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Action { @@ -17,6 +21,8 @@ pub struct Key<'a> { pub ctrl: bool, /// Alt as a modifier, not as the key that composes characters. pub alt: bool, + /// Cmd, the Windows key or Super: kitty's super. + pub platform: bool, pub action: Action, } @@ -33,16 +39,24 @@ pub struct Kitty { /// The bytes a key sends; none when it sends nothing or its text is the input method's to type. pub fn encode(key: &Key, app_cursor: bool, kitty: Kitty) -> Option> { let on = kitty.disambiguate || kitty.events || kitty.alternates || kitty.all || kitty.text; - if on { - encode_kitty(key, kitty) - } else { - legacy(key, app_cursor) + if !on { + return legacy(key, app_cursor); + } + if key.action == Action::Release && !kitty.events { + return None; + } + match functional(key.name) { + Some((number, end)) => named(key, number, end, app_cursor, kitty), + None => typed(key, printable(key.name)?, kitty), } } -/// xterm's modifier code: one plus shift 1, alt 2, ctrl 4. +/// xterm's modifier code: one plus shift 1, alt 2, ctrl 4, super 8. fn code(key: &Key) -> u32 { - 1 + u32::from(key.shift) + 2 * u32::from(key.alt) + 4 * u32::from(key.ctrl) + 1 + u32::from(key.shift) + + 2 * u32::from(key.alt) + + 4 * u32::from(key.ctrl) + + 8 * u32::from(key.platform) } /// The character a key name stands for, `space` included. @@ -54,152 +68,20 @@ fn printable(name: &str) -> Option { } } -fn legacy(key: &Key, app_cursor: bool) -> Option> { - if key.action == Action::Release { - return None; - } - if let Some(sequence) = function(key, app_cursor) { - return Some(sequence); - } - let ch = printable(key.name)?; - if key.ctrl { - if let Some(byte) = control(ch, key.shift) { - return Some(if key.alt { - vec![0x1B, byte] - } else { - vec![byte] - }); - } - return Some(format!("\x1b[{};{}u", ch as u32, code(key)).into_bytes()); - } - if key.alt { - let mut bytes = vec![0x1B]; - if key.text.is_empty() { - let typed = if key.shift { - ch.to_ascii_uppercase() - } else { - ch - }; - bytes.extend(typed.to_string().as_bytes()); - } else { - bytes.extend(key.text.as_bytes()); - } - return Some(bytes); - } - None +/// Whether the key types its text: some, none of it control, and no modifier that stops text. +fn types(key: &Key) -> bool { + let control = key.text.chars().any(char::is_control); + !key.text.is_empty() && !control && !key.ctrl && !key.alt && !key.platform } -/// xterm's bytes for a function, cursor or editing key. -fn function(key: &Key, app_cursor: bool) -> Option> { - let mods = code(key); - let modified = mods > 1; - let cursor = |end: char| match (modified, app_cursor) { - (true, _) => format!("\x1b[1;{mods}{end}"), - (false, true) => format!("\x1bO{end}"), - (false, false) => format!("\x1b[{end}"), - }; - let tilde = |n: u32| { - if modified { - format!("\x1b[{n};{mods}~") - } else { - format!("\x1b[{n}~") - } - }; - let ss3 = |end: char| { - if modified { - format!("\x1b[1;{mods}{end}") - } else { - format!("\x1bO{end}") - } - }; - let only = (key.shift, key.alt, key.ctrl); - let sequence = match key.name { - "up" => cursor('A'), - "down" => cursor('B'), - "right" => cursor('C'), - "left" => cursor('D'), - "home" => cursor('H'), - "end" => cursor('F'), - "insert" => tilde(2), - "delete" => tilde(3), - "pageup" => tilde(5), - "pagedown" => tilde(6), - "f1" => ss3('P'), - "f2" => ss3('Q'), - "f3" if modified => tilde(13), - "f3" => ss3('R'), - "f4" => ss3('S'), - "enter" => match only { - (false, false, false) => "\r".into(), - (false, true, false) => "\x1b\r".into(), - _ => format!("\x1b[27;{mods};13~"), - }, - "tab" => match only { - (false, false, false) => "\t".into(), - (true, false, false) => "\x1b[Z".into(), - (false, true, false) => "\x1b\t".into(), - _ => format!("\x1b[27;{mods};9~"), - }, - "escape" => match only { - (false, false, false) => "\x1b".into(), - (false, true, false) => "\x1b\x1b".into(), - _ => format!("\x1b[27;{mods};27~"), - }, - "backspace" => { - let byte = if key.ctrl { 0x08 } else { 0x7F }; - return Some(if key.alt { - vec![0x1B, byte] - } else { - vec![byte] - }); - } - name => tilde(FUNCTION_TILDES.iter().find(|(f, _)| *f == name)?.1), - }; - Some(sequence.into_bytes()) -} - -/// F5 to F25 and the numbers xterm sends them with. -const FUNCTION_TILDES: [(&str, u32); 21] = [ - ("f5", 15), - ("f6", 17), - ("f7", 18), - ("f8", 19), - ("f9", 20), - ("f10", 21), - ("f11", 23), - ("f12", 24), - ("f13", 25), - ("f14", 26), - ("f15", 28), - ("f16", 29), - ("f17", 31), - ("f18", 32), - ("f19", 33), - ("f20", 34), - ("f21", 42), - ("f22", 43), - ("f23", 44), - ("f24", 45), - ("f25", 46), -]; - -/// The C0 byte Ctrl turns a key into, as kitty and Ghostty do; `[`, `i` and `m` go as CSI u. -fn control(ch: char, shift: bool) -> Option { - if shift && ch != '@' { - return None; - } - Some(match ch { - ' ' | '2' | '@' => 0, - 'a'..='h' | 'j' | 'k' | 'l' | 'n'..='z' => ch as u8 - b'a' + 1, - '3' => 27, - '4' | '\\' => 28, - '5' | ']' => 29, - '6' | '^' | '~' => 30, - '7' | '/' | '_' => 31, - '8' | '?' => 127, - '0' | '1' | '9' => ch as u8, - _ => return None, - }) +/// The text's code points joined by colons, control codes left out. +fn code_points(text: &str) -> String { + let points: Vec = text + .chars() + .filter(|ch| !ch.is_control()) + .map(|ch| (ch as u32).to_string()) + .collect(); + points.join(":") } /// kitty's functional keys: the number and the final byte. @@ -230,68 +112,98 @@ fn functional(name: &str) -> Option<(u32, char)> { }) } -fn encode_kitty(key: &Key, kitty: Kitty) -> Option> { - let control_text = key.text.chars().any(char::is_control); - if key.action == Action::Release { - let legacy = matches!(key.name, "enter" | "tab" | "backspace"); - if !kitty.events || (legacy && !kitty.all) { +/// kitty's event field: 2 for a repeat, 3 for a release, none for a press or without the flag. +fn event(key: &Key, kitty: Kitty) -> Option { + match key.action { + Action::Press => None, + _ if !kitty.events => None, + Action::Repeat => Some(2), + Action::Release => Some(3), + } +} + +/// A functional key: legacy bytes until a flag that reshapes it is on. +fn named(key: &Key, number: u32, end: char, app_cursor: bool, kitty: Kitty) -> Option> { + if !kitty.events && !kitty.disambiguate && !kitty.all { + return legacy(key, app_cursor); + } + let lone = matches!(key.name, "enter" | "tab" | "backspace"); + if key.action == Action::Release && lone && !kitty.all { + return None; + } + let plain = code(key) == 1 && key.action != Action::Release && !kitty.all; + match key.name { + "enter" if plain => return Some(b"\r".to_vec()), + "tab" if plain => return Some(b"\t".to_vec()), + "backspace" if plain => return Some(vec![0x7F]), + "escape" if plain && !kitty.disambiguate => return Some(b"\x1b".to_vec()), + _ => {} + } + let mods = code(key); + let event = event(key, kitty); + let fields = match (mods, event) { + (_, Some(event)) => format!("{mods}:{event}"), + (1, None) => String::new(), + (_, None) => mods.to_string(), + }; + let sequence = match (end, fields.is_empty()) { + ('u' | '~', true) => format!("\x1b[{number}{end}"), + ('u' | '~', false) => format!("\x1b[{number};{fields}{end}"), + (_, true) => format!("\x1b[{end}"), + (_, false) => format!("\x1b[1;{fields}{end}"), + }; + Some(sequence.into_bytes()) +} + +/// A printable key: text, then legacy bytes, then CSI u once a flag has more to say. +fn typed(key: &Key, ch: char, kitty: Kitty) -> Option> { + let typing = types(key); + if typing && !kitty.all && key.action != Action::Release { + return None; + } + let shifted = key + .text + .chars() + .next() + .filter(|&typed| typing && typed != ch && !typed.is_control()) + .or_else(|| (key.shift && ch.is_ascii_lowercase()).then(|| ch.to_ascii_uppercase())) + .filter(|_| key.shift && kitty.alternates); + let event = event(key, kitty); + let text = typing && kitty.text && key.action != Action::Release; + let mods = code(key); + if shifted.is_none() && event.is_none() && !text { + if mods == 1 && !kitty.all { return None; } - } - let shift_typed = key.shift && !key.text.is_empty() && !control_text; - let bound = key.ctrl || key.alt || (key.shift && !shift_typed); - if !kitty.all && !bound && key.action != Action::Release { - match key.name { - "enter" => return Some(b"\r".to_vec()), - "tab" => return Some(b"\t".to_vec()), - "backspace" => return Some(vec![0x7F]), - _ if !key.text.is_empty() && !control_text => return None, - _ => {} + if mods > 1 + && !kitty.disambiguate + && !kitty.all + && let Some(bytes) = printable_key(key, ch) + { + return Some(bytes); } } - let (number, end) = functional(key.name) - .or_else(|| printable(key.name).map(|ch| (ch.to_ascii_lowercase() as u32, 'u')))?; - let mods = code(key); - let event = match key.action { - Action::Press => 1, - Action::Repeat => 2, - Action::Release => 3, - }; - let event = if kitty.events { event } else { 1 }; - let mut out = String::from("\x1b["); - if end != 'u' && end != '~' { - if event > 1 { - out.push_str(&format!("1;{mods}:{event}")); - } else if mods > 1 { - out.push_str(&format!("1;{mods}")); - } - out.push(end); - return Some(out.into_bytes()); - } - out.push_str(&number.to_string()); - let shifted = key.text.chars().next().filter(|&typed| { - kitty.alternates && key.shift && typed as u32 != number && !typed.is_control() - }); + let mut out = format!("\x1b[{}", ch.to_ascii_lowercase() as u32); if let Some(shifted) = shifted { out.push_str(&format!(":{}", shifted as u32)); } - let wrote_mods = event > 1 || mods > 1; - if event > 1 { - out.push_str(&format!(";{mods}:{event}")); - } else if mods > 1 { - out.push_str(&format!(";{mods}")); + let second = match (mods, event) { + (_, Some(event)) => format!("{mods}:{event}"), + (1, None) => String::new(), + (_, None) => mods.to_string(), + }; + let third = if text { + code_points(key.text) + } else { + String::new() + }; + if !second.is_empty() || !third.is_empty() { + out.push_str(&format!(";{second}")); } - let text: Vec = key - .text - .chars() - .filter(|ch| !ch.is_control()) - .map(|ch| (ch as u32).to_string()) - .collect(); - if kitty.text && key.action != Action::Release && !key.ctrl && !key.alt && !text.is_empty() { - out.push_str(if wrote_mods { ";" } else { ";;" }); - out.push_str(&text.join(":")); + if !third.is_empty() { + out.push_str(&format!(";{third}")); } - out.push(end); + out.push('u'); Some(out.into_bytes()) } diff --git a/src/term/keys/legacy.rs b/src/term/keys/legacy.rs new file mode 100644 index 0000000..465c4b8 --- /dev/null +++ b/src/term/keys/legacy.rs @@ -0,0 +1,151 @@ +use super::{Action, Key, code, printable}; + +/// xterm's encoding, as Ghostty sends it with no kitty flags set. +pub(super) fn legacy(key: &Key, app_cursor: bool) -> Option> { + if key.action == Action::Release { + return None; + } + if let Some(sequence) = function(key, app_cursor) { + return Some(sequence); + } + printable_key(key, printable(key.name)?) +} + +/// What Ctrl or Alt makes of a printable key; none for text and for Cmd. +pub(super) fn printable_key(key: &Key, ch: char) -> Option> { + if key.platform { + return None; + } + if key.ctrl { + if let Some(byte) = control(ch, key.shift) { + return Some(if key.alt { + vec![0x1B, byte] + } else { + vec![byte] + }); + } + return Some(format!("\x1b[{};{}u", ch as u32, code(key)).into_bytes()); + } + if !key.alt { + return None; + } + let mut bytes = vec![0x1B]; + if key.text.is_empty() { + let typed = if key.shift { + ch.to_ascii_uppercase() + } else { + ch + }; + bytes.extend(typed.to_string().as_bytes()); + } else { + bytes.extend(key.text.as_bytes()); + } + Some(bytes) +} + +/// xterm's bytes for a function, cursor or editing key. +pub(super) fn function(key: &Key, app_cursor: bool) -> Option> { + let mods = code(key); + let modified = mods > 1; + let cursor = |end: char| match (modified, app_cursor) { + (true, _) => format!("\x1b[1;{mods}{end}"), + (false, true) => format!("\x1bO{end}"), + (false, false) => format!("\x1b[{end}"), + }; + let tilde = |n: u32| { + if modified { + format!("\x1b[{n};{mods}~") + } else { + format!("\x1b[{n}~") + } + }; + let ss3 = |end: char| { + if modified { + format!("\x1b[1;{mods}{end}") + } else { + format!("\x1bO{end}") + } + }; + let alt_only = mods == 3; + let sequence = match key.name { + "up" => cursor('A'), + "down" => cursor('B'), + "right" => cursor('C'), + "left" => cursor('D'), + "home" => cursor('H'), + "end" => cursor('F'), + "insert" => tilde(2), + "delete" => tilde(3), + "pageup" => tilde(5), + "pagedown" => tilde(6), + "f1" => ss3('P'), + "f2" => ss3('Q'), + "f3" if modified => tilde(13), + "f3" => ss3('R'), + "f4" => ss3('S'), + "enter" if !modified => "\r".into(), + "enter" if alt_only => "\x1b\r".into(), + "enter" => format!("\x1b[27;{mods};13~"), + "tab" if !modified => "\t".into(), + "tab" if mods == 2 => "\x1b[Z".into(), + "tab" if alt_only => "\x1b\t".into(), + "tab" => format!("\x1b[27;{mods};9~"), + "escape" if !modified => "\x1b".into(), + "escape" if alt_only => "\x1b\x1b".into(), + "escape" => format!("\x1b[27;{mods};27~"), + "backspace" => { + let byte = if key.ctrl { 0x08 } else { 0x7F }; + return Some(if key.alt { + vec![0x1B, byte] + } else { + vec![byte] + }); + } + name => tilde(FUNCTION_TILDES.iter().find(|(f, _)| *f == name)?.1), + }; + Some(sequence.into_bytes()) +} + +/// F5 to F25 and the numbers xterm sends them with. +pub(super) const FUNCTION_TILDES: [(&str, u32); 21] = [ + ("f5", 15), + ("f6", 17), + ("f7", 18), + ("f8", 19), + ("f9", 20), + ("f10", 21), + ("f11", 23), + ("f12", 24), + ("f13", 25), + ("f14", 26), + ("f15", 28), + ("f16", 29), + ("f17", 31), + ("f18", 32), + ("f19", 33), + ("f20", 34), + ("f21", 42), + ("f22", 43), + ("f23", 44), + ("f24", 45), + ("f25", 46), +]; + +/// The C0 byte Ctrl turns a key into, as kitty and Ghostty do; `[`, `i` and `m` go as CSI u. +fn control(ch: char, shift: bool) -> Option { + if shift && ch != '@' { + return None; + } + Some(match ch { + ' ' | '2' | '@' => 0, + 'a'..='h' | 'j' | 'k' | 'l' | 'n'..='z' => ch as u8 - b'a' + 1, + '3' => 27, + '4' | '\\' => 28, + '5' | ']' => 29, + '6' | '^' | '~' => 30, + '7' | '/' | '_' => 31, + '8' | '?' => 127, + '0' | '1' | '9' => ch as u8, + _ => return None, + }) +} diff --git a/src/term/keys/tests.rs b/src/term/keys/tests.rs index 2e1a72c..50260b7 100644 --- a/src/term/keys/tests.rs +++ b/src/term/keys/tests.rs @@ -10,6 +10,7 @@ fn key(spec: &'static str, text: &'static str) -> Key<'static> { shift: parts.contains(&"shift"), ctrl: parts.contains(&"ctrl"), alt: parts.contains(&"alt"), + platform: parts.contains(&"cmd"), action: Action::Press, } } @@ -258,3 +259,65 @@ fn kitty_report_all_alternates_and_text() { }; assert_eq!(kitty(released, everything).as_deref(), Some("\x1b[13;1:3u")); } + +#[test] +fn flags_that_only_add_fields_keep_legacy_keys() { + let alternates = Kitty { + alternates: true, + ..Kitty::default() + }; + let bytes = |spec, flags, app_cursor| encode(&key(spec, ""), app_cursor, flags); + assert_eq!(bytes("ctrl+c", alternates, false), Some(vec![0x03])); + assert_eq!(bytes("escape", alternates, false), Some(b"\x1b".to_vec())); + assert_eq!(bytes("up", alternates, true), Some(b"\x1bOA".to_vec())); + assert_eq!(bytes("f1", alternates, false), Some(b"\x1bOP".to_vec())); + assert_eq!( + bytes("ctrl+shift+a", alternates, false), + Some(b"\x1b[97:65;6u".to_vec()) + ); + let events = Kitty { + events: true, + ..Kitty::default() + }; + let released = |spec| Key { + action: Action::Release, + ..key(spec, "") + }; + assert_eq!(bytes("ctrl+c", events, false), Some(vec![0x03])); + assert_eq!( + encode(&released("ctrl+c"), false, events), + Some(b"\x1b[99;5:3u".to_vec()) + ); + assert_eq!(bytes("f1", events, false), Some(b"\x1b[P".to_vec())); + assert_eq!(bytes("escape", events, false), Some(b"\x1b".to_vec())); + assert_eq!( + encode(&released("escape"), false, events), + Some(b"\x1b[27;1:3u".to_vec()) + ); +} + +#[test] +fn super_is_kittys_eighth_bit() { + let events = Kitty { + events: true, + ..DISAMBIGUATE + }; + assert_eq!( + kitty(key("cmd+ctrl+c", ""), DISAMBIGUATE).as_deref(), + Some("\x1b[99;13u") + ); + let released = Key { + action: Action::Release, + ..key("cmd+ctrl+c", "") + }; + assert_eq!(kitty(released, events).as_deref(), Some("\x1b[99;13:3u")); + assert_eq!(legacy(key("cmd+c", ""), false), None, "Cmd types nothing"); + assert_eq!( + legacy(key("cmd+up", ""), false).as_deref(), + Some("\x1b[1;9A") + ); + assert_eq!( + legacy(key("cmd+enter", ""), false).as_deref(), + Some("\x1b[27;9;13~") + ); +} diff --git a/src/term/mac.rs b/src/term/mac.rs index 6a26b87..6871209 100644 --- a/src/term/mac.rs +++ b/src/term/mac.rs @@ -1,6 +1,7 @@ +use block::ConcreteBlock; use objc::{ class, msg_send, - runtime::{NO, Object}, + runtime::{BOOL, NO, Object}, sel, sel_impl, }; @@ -8,23 +9,40 @@ use objc::{ const KEY_DOWN: u64 = 10; const KEY_UP: u64 = 11; +/// The Command key's bit in an event's modifier flags. +const COMMAND: u64 = 1 << 20; + /// The device bits of the left and right Option keys in an event's modifier flags. const LEFT_OPTION: u64 = 0x20; const RIGHT_OPTION: u64 = 0x40; -/// Which Option keys are down in the key event AppKit is handling: left, then right. -pub fn option_sides() -> anyhow::Result<(bool, bool)> { +/// What gpui drops from the key event AppKit is handling. +#[derive(Clone, Copy, Debug)] +pub struct KeyFacts { + pub left_option: bool, + pub right_option: bool, + /// Held down; gpui loses this for keys AppKit routes through commands. + pub repeat: bool, +} + +/// The facts of the key event AppKit is handling now. +pub fn key_facts() -> anyhow::Result { unsafe { let app: *mut Object = msg_send![class!(NSApplication), sharedApplication]; let event: *mut Object = msg_send![app, currentEvent]; - anyhow::ensure!(!event.is_null(), "macOS: no key event to read Option from"); + anyhow::ensure!(!event.is_null(), "macOS: no key event to read"); let kind: u64 = msg_send![event, type]; anyhow::ensure!( kind == KEY_DOWN || kind == KEY_UP, "macOS: the current event is type {kind}, not a key" ); let flags: u64 = msg_send![event, modifierFlags]; - Ok((flags & LEFT_OPTION != 0, flags & RIGHT_OPTION != 0)) + let repeat: BOOL = msg_send![event, isARepeat]; + Ok(KeyFacts { + left_option: flags & LEFT_OPTION != 0, + right_option: flags & RIGHT_OPTION != 0, + repeat: repeat != NO, + }) } } @@ -43,3 +61,33 @@ pub fn repeat_held_keys() { } log::info!("macOS: held keys repeat"); } + +/// Hands the focused view the Command key-ups AppKit swallows, as Ghostty does. +pub fn deliver_command_key_ups() -> anyhow::Result<()> { + let handler = ConcreteBlock::new(|event: *mut Object| -> *mut Object { + unsafe { + let flags: u64 = msg_send![event, modifierFlags]; + let window: *mut Object = msg_send![event, window]; + if flags & COMMAND == 0 || window.is_null() { + return event; + } + let view: *mut Object = msg_send![window, firstResponder]; + let _: () = msg_send![view, keyUp: event]; + std::ptr::null_mut() + } + }) + .copy(); + unsafe { + let monitor: *mut Object = msg_send![ + class!(NSEvent), + addLocalMonitorForEventsMatchingMask: 1u64 << KEY_UP + handler: &*handler + ]; + anyhow::ensure!( + !monitor.is_null(), + "macOS: AppKit refused the key-up monitor" + ); + } + log::info!("macOS: Command key-ups reach the focused view"); + Ok(()) +} diff --git a/src/term/view.rs b/src/term/view.rs index 67c7209..d40a067 100644 --- a/src/term/view.rs +++ b/src/term/view.rs @@ -11,7 +11,7 @@ use gpui::{ use super::{ frame::frame, ink::Ink, - input::{self, SendBackTab, SendTab}, + input, paint::{self, Look}, pty::{Events, Pty, PtyEvent, config, fit}, }; @@ -261,8 +261,6 @@ impl Render for TerminalView { .track_focus(&self.focus) .on_key_down(cx.listener(Self::key_down)) .on_key_up(cx.listener(Self::key_up)) - .on_action(cx.listener(|view, _: &SendTab, _, cx| view.tab(false, cx))) - .on_action(cx.listener(|view, _: &SendBackTab, _, cx| view.tab(true, cx))) .size_full() .px(px(settings.padding_x)) .py(px(settings.padding_y))