From 25365a98c866e0dc5b02cdddca4ff3d50b33959f 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:22:52 -0400 Subject: [PATCH] T08: keys: xterm and kitty encoding, Option as Alt by side, IME composition at the cursor; shot types real AppKit keys --- AGENTS.md | 7 +- Cargo.toml | 4 +- PRD.md | 2 +- TASKS.md | 2 +- examples/shot.rs | 122 +++++- key.zig | 876 +++++++++++++++++++++++++++++++++++++++++ src/app.rs | 7 +- src/term/frame.rs | 18 +- src/term/input.rs | 222 +++++++++++ src/term/keys.rs | 299 ++++++++++++++ src/term/keys/tests.rs | 260 ++++++++++++ src/term/mac.rs | 45 +++ src/term/mod.rs | 4 + src/term/paint.rs | 75 +++- src/term/view.rs | 34 +- 15 files changed, 1949 insertions(+), 28 deletions(-) create mode 100644 key.zig create mode 100644 src/term/input.rs create mode 100644 src/term/keys.rs create mode 100644 src/term/keys/tests.rs create mode 100644 src/term/mac.rs diff --git a/AGENTS.md b/AGENTS.md index 32eb87e..43011b7 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 -- [args…]` runs a program in the main window and writes a PNG of it (macOS; no permission needed for the app's own window). +- `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. - `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. +- `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. - `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,4 +44,7 @@ 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: 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: settings refuse unknown fields; a bad settings file stops the start and is never overwritten. diff --git a/Cargo.toml b/Cargo.toml index 05e04f9..8dd0398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,10 +29,12 @@ libc = "0.2" rustix-openpty = "0.2" signal-hook = "0.4" +[target.'cfg(target_os = "macos")'.dependencies] +objc = "0.2" + [target.'cfg(target_os = "macos")'.dev-dependencies] core-graphics = "0.24" image = { version = "0.25", default-features = false, features = ["png"] } -objc = "0.2" raw-window-handle = "0.6" [lints.rust] diff --git a/PRD.md b/PRD.md index 0789620..57db425 100644 --- a/PRD.md +++ b/PRD.md @@ -47,7 +47,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a - Box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) painted as geometry, so lines and segments join across cells. - Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font. - Cursor: block, bar, underline, hollow block; blinking per setting and per DECSCUSR; hollow when the window is not focused. -- Keys: xterm encoding with modifiers, application cursor and keypad modes, Option as Alt on macOS, the kitty keyboard protocol as the program sets its flags. +- Keys: xterm encoding with modifiers, application cursor mode, Option as Alt on macOS, the kitty keyboard protocol as the program sets its flags. Keypad keys type their characters, as Ghostty's do with mode 1035 on by default. - IME: marked text drawn at the cursor; committed text sent to the program. - Mouse: select by character, word (double press) and line (triple press); Shift extends; mouse reporting (X10, normal, button, any; SGR and UTF-8); wheel scrolls back, or reports, or sends arrows on the alternate screen. - Clipboard: copy, paste (bracketed when asked), copy on select per setting, OSC 52 writes. diff --git a/TASKS.md b/TASKS.md index c3bcb0e..6415728 100644 --- a/TASKS.md +++ b/TASKS.md @@ -20,7 +20,7 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co - [x] T05 Shell integration for zsh, bash, fish, PowerShell, injected at launch; tests that zsh and bash report their folder. - [x] T06 Terminal view: frame from the grid, colors and styles, wide and combining cells, cursor shapes and blink, font, line height, padding, resize. - [x] T07 Box drawing, block elements and powerline shapes (U+E0B0–E0B7) painted as geometry; tests. -- [ ] T08 Keys: xterm encoding, modes, Option as Alt, kitty keyboard protocol, IME; tests. +- [ ] T08 Keys: xterm encoding, cursor mode, Option as Alt, kitty keyboard protocol, IME; tests. - [ ] T09 Mouse: selection, mouse reporting, wheel and scrollback, clipboard, copy on select, OSC 52, links; tests. - [ ] T10 Focus reporting, title, silent bell, scrollback limit, find. diff --git a/examples/shot.rs b/examples/shot.rs index 6c8a312..6a4c0d3 100644 --- a/examples/shot.rs +++ b/examples/shot.rs @@ -1,4 +1,4 @@ -//! `cargo run --example shot -- [args…]`: runs a program in the main window, then photographs the window. +//! `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. #[cfg(target_os = "macos")] fn main() { @@ -9,7 +9,14 @@ fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("elyterminal=info")) .init(); - let mut args = std::env::args().skip(1); + let mut args = std::env::args().skip(1).peekable(); + let keys: Vec = if args.peek().map(String::as_str) == Some("--keys") { + args.next(); + let keys = args.next().expect("keys after --keys"); + keys.split_whitespace().map(String::from).collect() + } else { + Vec::new() + }; let out = args.next().expect("a png path"); let program = args.next().expect("a program"); let launch = Launch { @@ -25,7 +32,18 @@ fn main() { let window = app::open(launch, cx).expect("the window opens"); cx.activate(true); cx.spawn(async move |cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; + cx.background_executor().timer(Duration::from_secs(1)).await; + let number = window + .update(cx, |_, window, _| capture::number(window)) + .expect("the window lives") + .expect("a window number"); + for spec in &keys { + typing::post(spec, number).expect("a key event"); + cx.background_executor() + .timer(Duration::from_millis(120)) + .await; + } + cx.background_executor().timer(Duration::from_secs(1)).await; let number = window .update(cx, |_, window, _| capture::number(window)) .expect("the window lives") @@ -96,6 +114,104 @@ mod capture { } } +#[cfg(target_os = "macos")] +mod typing { + use anyhow::Context as _; + use core_graphics::geometry::CGPoint; + use objc::{ + class, msg_send, + runtime::{NO, Object}, + sel, sel_impl, + }; + + /// US layout key codes and what each key types. + const KEYS: [(&str, u16, &str); 17] = [ + ("a", 0, "a"), + ("c", 8, "c"), + ("e", 14, "e"), + ("f", 3, "f"), + ("i", 34, "i"), + ("s", 1, "s"), + ("[", 33, "["), + ("space", 49, " "), + ("tab", 48, "\t"), + ("enter", 36, "\r"), + ("backspace", 51, "\u{7f}"), + ("escape", 53, "\u{1b}"), + ("up", 126, "\u{F700}"), + ("down", 125, "\u{F701}"), + ("left", 123, "\u{F702}"), + ("f1", 122, "\u{F704}"), + ("f5", 96, "\u{F708}"), + ]; + + /// Posts `spec`, such as `ctrl-c` or `lopt-f`, as a key going down and up on window `number`. + pub fn post(spec: &str, number: u32) -> anyhow::Result<()> { + let mut parts: Vec<&str> = spec.split('-').collect(); + let name = parts.pop().context("an empty key")?; + let (_, code, plain) = KEYS + .iter() + .find(|(key, ..)| *key == name) + .with_context(|| format!("no key code for {name}"))?; + let mut flags: u64 = 0; + for modifier in &parts { + flags |= match *modifier { + "shift" => 0x20000 | 0x02, + "ctrl" => 0x40000 | 0x01, + "lopt" => 0x80000 | 0x20, + "ropt" => 0x80000 | 0x40, + "cmd" => 0x100000 | 0x08, + other => anyhow::bail!("no modifier {other}"), + }; + } + if plain + .chars() + .next() + .is_some_and(|ch| ('\u{F700}'..='\u{F8FF}').contains(&ch)) + { + flags |= 0x800000 | 0x200000; + } + let ignoring = if flags & 0x20000 != 0 { + plain.to_uppercase() + } else { + plain.to_string() + }; + let typed = match plain.chars().next() { + _ if flags & 0x80000 != 0 => String::new(), + Some(letter @ 'a'..='z') if flags & 0x40000 != 0 => { + char::from(letter as u8 - b'a' + 1).to_string() + } + _ => ignoring.clone(), + }; + 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 + location: CGPoint::new(0.0, 0.0) + modifierFlags: flags + timestamp: 0.0f64 + windowNumber: i64::from(number) + context: std::ptr::null_mut::() + characters: string(&typed) + charactersIgnoringModifiers: string(&ignoring) + isARepeat: NO + keyCode: *code + ]; + anyhow::ensure!(!event.is_null(), "AppKit refused the key {spec}"); + let app: *mut Object = msg_send![class!(NSApplication), sharedApplication]; + let _: () = msg_send![app, postEvent: event atStart: NO]; + } + } + log::info!("shot: typed {spec}"); + Ok(()) + } +} + #[cfg(not(target_os = "macos"))] fn main() { eprintln!("shot photographs windows on macOS only"); diff --git a/key.zig b/key.zig new file mode 100644 index 0000000..1bf6491 --- /dev/null +++ b/key.zig @@ -0,0 +1,876 @@ +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/src/app.rs b/src/app.rs index 5425d41..d5569d6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,9 +7,11 @@ use gpui::{ Render, Styled, TitlebarOptions, Window, WindowBounds, WindowHandle, WindowOptions, px, size, }; +#[cfg(target_os = "macos")] +use crate::term::mac; use crate::{ settings::SettingsStore, - term::{ICONS, Launch, MONO, TerminalView, config, shell::ShellIntegration, spawn}, + term::{ICONS, Launch, MONO, TerminalView, config, input, shell::ShellIntegration, spawn}, themes, }; @@ -52,6 +54,9 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> { } SettingsStore::init(cx)?; ShellIntegration::init(cx)?; + input::bind_keys(cx); + #[cfg(target_os = "macos")] + mac::repeat_held_keys(); let family = &SettingsStore::get(cx).font_family; anyhow::ensure!( cx.text_system().all_font_names().contains(family), diff --git a/src/term/frame.rs b/src/term/frame.rs index 2ae8d25..d51eddf 100644 --- a/src/term/frame.rs +++ b/src/term/frame.rs @@ -60,6 +60,8 @@ pub struct Cursor { pub struct Frame { pub rows: Vec, pub cursor: Option, + /// The cursor's row and column while on screen, shown or hidden. + pub caret: Option<(usize, usize)>, } impl Frame { @@ -121,7 +123,15 @@ pub fn frame(term: &Term, ink: &Ink) -> Frame { rows[row].alone.push((point.column.0, alone, look)); } } - Frame { rows, cursor } + let row = at.line.0 + offset; + let caret = (0..rows.len() as i32) + .contains(&row) + .then_some((row as usize, at.column.0)); + Frame { + rows, + cursor, + caret, + } } fn push(row: &mut Row, shown: char, style: Style) { @@ -272,6 +282,12 @@ mod tests { assert_eq!(frame.rows[0].alone[0].2.fg, ink.fg); assert_eq!(frame.text(), "a\u{2500}\u{E0B0}\u{2500}\u{301}"); assert_eq!(frame.cursor.expect("a cursor").text, "\u{E0B0}"); + let (gone, _) = shown(b"ab\x1b[?25l"); + assert_eq!( + (gone.cursor, gone.caret), + (None, Some((0, 2))), + "a hidden cursor keeps its cell" + ); let (hidden, _) = shown(b"\x1b[8m\xe2\x94\x80"); assert!(hidden.rows[0].alone.is_empty(), "hidden lines draw nothing"); } diff --git a/src/term/input.rs b/src/term/input.rs new file mode 100644 index 0000000..e6e79d5 --- /dev/null +++ b/src/term/input.rs @@ -0,0 +1,222 @@ +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, +}; + +use super::{ + keys::{self, Action, Key, Kitty}, + 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. +pub fn bind_keys(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("tab", SendTab, Some(CONTEXT)), + KeyBinding::new("shift-tab", SendBackTab, Some(CONTEXT)), + ]); +} + +impl TerminalView { + /// Bytes from the keyboard: back to the newest line, then to the program. + pub(super) fn input(&mut self, bytes: Vec, cx: &mut Context) { + self.pty.term.lock().scroll_display(Scroll::Bottom); + self.pty.write(bytes); + cx.notify(); + } + + pub(super) fn key_down( + &mut self, + event: &KeyDownEvent, + _: &mut Window, + cx: &mut Context, + ) { + if event.prefer_character_input { + return; + } + let action = if event.is_held { + Action::Repeat + } else { + Action::Press + }; + if self.send(&event.keystroke, action, 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) { + 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 { + let modifiers = keystroke.modifiers; + if modifiers.platform { + return false; + } + let alt = modifiers.alt && self.alt_is_meta(cx); + let text = match &keystroke.key_char { + Some(_) if alt && cfg!(target_os = "macos") => "", + Some(text) => text.as_str(), + None => "", + }; + let key = Key { + name: &keystroke.key, + text, + shift: modifiers.shift, + ctrl: modifiers.control, + alt, + 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), + events: mode.contains(TermMode::REPORT_EVENT_TYPES), + alternates: mode.contains(TermMode::REPORT_ALTERNATE_KEYS), + 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 { + return false; + }; + log::trace!("terminal {}: {key:?} sends {bytes:?}", self.pty.pid); + self.input(bytes, cx); + true + } + + /// 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 { + 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), + } + } + + #[cfg(not(target_os = "macos"))] + fn alt_is_meta(&self, _: &App) -> bool { + true + } +} + +impl EntityInputHandler for TerminalView { + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + None + } + + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option { + let end = self + .marked + .as_ref() + .map_or(0, |marked| marked.encode_utf16().count()); + Some(UTF16Selection { + range: end..end, + reversed: false, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + self.marked + .as_ref() + .map(|marked| 0..marked.encode_utf16().count()) + } + + fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { + log::debug!("terminal {}: composing ends", self.pty.pid); + self.marked = None; + cx.notify(); + } + + fn replace_text_in_range( + &mut self, + _: Option>, + text: &str, + _: &mut Window, + cx: &mut Context, + ) { + self.marked = None; + if !text.is_empty() { + log::trace!("terminal {}: typed {text:?}", self.pty.pid); + self.input(text.as_bytes().to_vec(), cx); + } + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + _: Option>, + text: &str, + _: Option>, + _: &mut Window, + cx: &mut Context, + ) { + log::debug!("terminal {}: composing {text:?}", self.pty.pid); + self.marked = (!text.is_empty()).then(|| text.to_string()); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + _: Range, + _: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + Some(self.caret) + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut Context, + ) -> Option { + None + } +} diff --git a/src/term/keys.rs b/src/term/keys.rs new file mode 100644 index 0000000..2326f03 --- /dev/null +++ b/src/term/keys.rs @@ -0,0 +1,299 @@ +/// Whether a key went down, repeated, or came up. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Press, + Repeat, + Release, +} + +/// A key as gpui names it, what it types, and the modifiers that count. +#[derive(Clone, Copy, Debug)] +pub struct Key<'a> { + /// gpui's name: `a`, `!`, `space`, `enter`, `f5`. + pub name: &'a str, + /// What the key types; empty when it types nothing. + pub text: &'a str, + pub shift: bool, + pub ctrl: bool, + /// Alt as a modifier, not as the key that composes characters. + pub alt: bool, + pub action: Action, +} + +/// The kitty keyboard flags a program set. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Kitty { + pub disambiguate: bool, + pub events: bool, + pub alternates: bool, + pub all: bool, + pub text: bool, +} + +/// 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) + } +} + +/// xterm's modifier code: one plus shift 1, alt 2, ctrl 4. +fn code(key: &Key) -> u32 { + 1 + u32::from(key.shift) + 2 * u32::from(key.alt) + 4 * u32::from(key.ctrl) +} + +/// The character a key name stands for, `space` included. +fn printable(name: &str) -> Option { + let mut chars = name.chars(); + match (chars.next(), chars.next()) { + (Some(ch), None) => Some(ch), + _ => (name == "space").then_some(' '), + } +} + +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 +} + +/// 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, + }) +} + +/// kitty's functional keys: the number and the final byte. +fn functional(name: &str) -> Option<(u32, char)> { + Some(match name { + "escape" => (27, 'u'), + "enter" => (13, 'u'), + "tab" => (9, 'u'), + "backspace" => (127, 'u'), + "insert" => (2, '~'), + "delete" => (3, '~'), + "left" => (1, 'D'), + "right" => (1, 'C'), + "up" => (1, 'A'), + "down" => (1, 'B'), + "pageup" => (5, '~'), + "pagedown" => (6, '~'), + "home" => (1, 'H'), + "end" => (1, 'F'), + "f1" => (1, 'P'), + "f2" => (1, 'Q'), + "f3" => (13, '~'), + "f4" => (1, 'S'), + name => match FUNCTION_TILDES.iter().position(|(f, _)| *f == name)? { + index @ 0..=7 => (FUNCTION_TILDES[index].1, '~'), + index => (57376 + index as u32 - 8, 'u'), + }, + }) +} + +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) { + 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, + _ => {} + } + } + 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() + }); + 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 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(":")); + } + out.push(end); + Some(out.into_bytes()) +} + +#[cfg(test)] +mod tests; diff --git a/src/term/keys/tests.rs b/src/term/keys/tests.rs new file mode 100644 index 0000000..2e1a72c --- /dev/null +++ b/src/term/keys/tests.rs @@ -0,0 +1,260 @@ +use super::*; + +/// A key pressed with modifiers written as `ctrl+alt+shift+name`. +fn key(spec: &'static str, text: &'static str) -> Key<'static> { + let mut parts: Vec<&str> = spec.split('+').collect(); + let name = parts.pop().expect("a name"); + Key { + name, + text, + shift: parts.contains(&"shift"), + ctrl: parts.contains(&"ctrl"), + alt: parts.contains(&"alt"), + action: Action::Press, + } +} + +fn legacy(key: Key, app_cursor: bool) -> Option { + encode(&key, app_cursor, Kitty::default()).map(|bytes| String::from_utf8(bytes).expect("utf-8")) +} + +fn kitty(key: Key, flags: Kitty) -> Option { + encode(&key, false, flags).map(|bytes| String::from_utf8(bytes).expect("utf-8")) +} + +const DISAMBIGUATE: Kitty = Kitty { + disambiguate: true, + events: false, + alternates: false, + all: false, + text: false, +}; + +#[test] +fn plain_text_is_left_to_the_input_method() { + assert_eq!(legacy(key("a", "a"), false), None); + assert_eq!(legacy(key("shift+a", "A"), false), None); + assert_eq!(legacy(key("space", " "), false), None); + assert_eq!(legacy(key("ctrl+a", ""), false).as_deref(), Some("\x01")); + let released = Key { + action: Action::Release, + ..key("enter", "\n") + }; + assert_eq!(legacy(released, false), None, "legacy sends no releases"); +} + +#[test] +fn cursor_keys_follow_decckm_and_modifiers() { + assert_eq!(legacy(key("up", ""), false).as_deref(), Some("\x1b[A")); + assert_eq!(legacy(key("up", ""), true).as_deref(), Some("\x1bOA")); + assert_eq!( + legacy(key("ctrl+up", ""), true).as_deref(), + Some("\x1b[1;5A") + ); + assert_eq!( + legacy(key("alt+shift+left", ""), false).as_deref(), + Some("\x1b[1;4D") + ); + assert_eq!(legacy(key("home", ""), true).as_deref(), Some("\x1bOH")); + assert_eq!(legacy(key("end", ""), false).as_deref(), Some("\x1b[F")); +} + +#[test] +fn function_and_editing_keys_send_xterm_sequences() { + let cases = [ + ("f1", "\x1bOP"), + ("ctrl+f1", "\x1b[1;5P"), + ("f3", "\x1bOR"), + ("shift+f3", "\x1b[13;2~"), + ("f4", "\x1bOS"), + ("f5", "\x1b[15~"), + ("shift+f5", "\x1b[15;2~"), + ("f12", "\x1b[24~"), + ("f25", "\x1b[46~"), + ("insert", "\x1b[2~"), + ("delete", "\x1b[3~"), + ("ctrl+pageup", "\x1b[5;5~"), + ("pagedown", "\x1b[6~"), + ]; + for (spec, want) in cases { + assert_eq!( + legacy(key(spec, ""), false).as_deref(), + Some(want), + "{spec}" + ); + } + assert_eq!(legacy(key("f26", ""), false), None); +} + +#[test] +fn enter_tab_escape_and_backspace_carry_their_modifiers() { + let cases: [(&str, &str, &[u8]); 15] = [ + ("enter", "\n", b"\r"), + ("shift+enter", "\n", b"\x1b[27;2;13~"), + ("alt+enter", "\n", b"\x1b\r"), + ("ctrl+enter", "", b"\x1b[27;5;13~"), + ("tab", "\t", b"\t"), + ("shift+tab", "", b"\x1b[Z"), + ("alt+tab", "\t", b"\x1b\t"), + ("ctrl+tab", "", b"\x1b[27;5;9~"), + ("escape", "", b"\x1b"), + ("alt+escape", "", b"\x1b\x1b"), + ("shift+escape", "", b"\x1b[27;2;27~"), + ("backspace", "", b"\x7f"), + ("ctrl+backspace", "", b"\x08"), + ("alt+backspace", "", b"\x1b\x7f"), + ("ctrl+alt+backspace", "", b"\x1b\x08"), + ]; + for (spec, text, want) in cases { + assert_eq!( + encode(&key(spec, text), false, Kitty::default()).as_deref(), + Some(want), + "{spec}" + ); + } +} + +#[test] +fn ctrl_makes_c0_bytes_or_fixterms_csi_u() { + let bytes = |spec| encode(&key(spec, ""), false, Kitty::default()); + assert_eq!(bytes("ctrl+c"), Some(vec![0x03])); + assert_eq!(bytes("ctrl+alt+c"), Some(vec![0x1B, 0x03])); + assert_eq!(bytes("ctrl+space"), Some(vec![0x00])); + assert_eq!(bytes("ctrl+_"), Some(vec![31])); + assert_eq!(bytes("ctrl+?"), Some(vec![127])); + assert_eq!(bytes("ctrl+2"), Some(vec![0x00])); + assert_eq!(bytes("ctrl+["), Some(b"\x1b[91;5u".to_vec())); + assert_eq!(bytes("ctrl+i"), Some(b"\x1b[105;5u".to_vec())); + assert_eq!(bytes("ctrl+m"), Some(b"\x1b[109;5u".to_vec())); + assert_eq!(bytes("ctrl+shift+a"), Some(b"\x1b[97;6u".to_vec())); +} + +#[test] +fn alt_prefixes_escape_to_what_the_key_types() { + assert_eq!(legacy(key("alt+a", "a"), false).as_deref(), Some("\x1ba")); + assert_eq!( + legacy(key("alt+shift+a", ""), false).as_deref(), + Some("\x1bA") + ); + assert_eq!(legacy(key("alt+e", "é"), false).as_deref(), Some("\x1bé")); + assert_eq!( + legacy(key("alt+space", " "), false).as_deref(), + Some("\x1b ") + ); +} + +#[test] +fn kitty_disambiguate_keeps_text_and_the_legacy_three() { + assert_eq!(kitty(key("a", "a"), DISAMBIGUATE), None); + assert_eq!(kitty(key("shift+a", "A"), DISAMBIGUATE), None); + assert_eq!( + kitty(key("enter", "\n"), DISAMBIGUATE).as_deref(), + Some("\r") + ); + assert_eq!(kitty(key("tab", "\t"), DISAMBIGUATE).as_deref(), Some("\t")); + assert_eq!( + kitty(key("backspace", ""), DISAMBIGUATE).as_deref(), + Some("\x7f") + ); + assert_eq!( + kitty(key("escape", ""), DISAMBIGUATE).as_deref(), + Some("\x1b[27u") + ); +} + +#[test] +fn kitty_reports_modified_keys_as_csi_u() { + let cases = [ + ("ctrl+a", "", "\x1b[97;5u"), + ("alt+a", "a", "\x1b[97;3u"), + ("ctrl+shift+a", "", "\x1b[97;6u"), + ("shift+enter", "\n", "\x1b[13;2u"), + ("shift+tab", "", "\x1b[9;2u"), + ("up", "", "\x1b[A"), + ("ctrl+up", "", "\x1b[1;5A"), + ("f1", "", "\x1b[P"), + ("f5", "", "\x1b[15~"), + ("ctrl+f5", "", "\x1b[15;5~"), + ("f13", "", "\x1b[57376u"), + ("f25", "", "\x1b[57388u"), + ]; + for (spec, text, want) in cases { + assert_eq!( + kitty(key(spec, text), DISAMBIGUATE).as_deref(), + Some(want), + "{spec}" + ); + } +} + +#[test] +fn kitty_events_report_repeats_and_releases() { + let flags = Kitty { + events: true, + ..DISAMBIGUATE + }; + let with = |spec, text, action| Key { + action, + ..key(spec, text) + }; + assert_eq!( + kitty(with("a", "a", Action::Repeat), flags), + None, + "a repeat types again" + ); + assert_eq!( + kitty(with("a", "a", Action::Release), flags).as_deref(), + Some("\x1b[97;1:3u") + ); + assert_eq!( + kitty(with("ctrl+a", "", Action::Repeat), flags).as_deref(), + Some("\x1b[97;5:2u") + ); + assert_eq!(kitty(with("enter", "\n", Action::Release), flags), None); + assert_eq!( + kitty(with("up", "", Action::Release), flags).as_deref(), + Some("\x1b[1;1:3A") + ); + assert_eq!(kitty(with("a", "a", Action::Release), DISAMBIGUATE), None); +} + +#[test] +fn kitty_report_all_alternates_and_text() { + let all = Kitty { + all: true, + ..DISAMBIGUATE + }; + assert_eq!(kitty(key("a", "a"), all).as_deref(), Some("\x1b[97u")); + assert_eq!( + kitty(key("shift+a", "A"), all).as_deref(), + Some("\x1b[97;2u") + ); + assert_eq!(kitty(key("enter", "\n"), all).as_deref(), Some("\x1b[13u")); + let alternates = Kitty { + alternates: true, + ..all + }; + assert_eq!( + kitty(key("shift+a", "A"), alternates).as_deref(), + Some("\x1b[97:65;2u") + ); + let text = Kitty { text: true, ..all }; + assert_eq!(kitty(key("a", "a"), text).as_deref(), Some("\x1b[97;;97u")); + assert_eq!( + kitty(key("shift+a", "A"), text).as_deref(), + Some("\x1b[97;2;65u") + ); + assert_eq!( + kitty(key("ctrl+a", ""), text).as_deref(), + Some("\x1b[97;5u") + ); + let released = Key { + action: Action::Release, + ..key("enter", "\n") + }; + let everything = Kitty { + events: true, + ..all + }; + assert_eq!(kitty(released, everything).as_deref(), Some("\x1b[13;1:3u")); +} diff --git a/src/term/mac.rs b/src/term/mac.rs new file mode 100644 index 0000000..6a26b87 --- /dev/null +++ b/src/term/mac.rs @@ -0,0 +1,45 @@ +use objc::{ + class, msg_send, + runtime::{NO, Object}, + sel, sel_impl, +}; + +/// AppKit's event types for a key going down and coming up. +const KEY_DOWN: u64 = 10; +const KEY_UP: u64 = 11; + +/// 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)> { + 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"); + 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)) + } +} + +/// Turns off the accent menu a held key opens, so held keys repeat as in other terminals. +pub fn repeat_held_keys() { + unsafe { + let key: *mut Object = msg_send![ + class!(NSString), + stringWithUTF8String: c"ApplePressAndHoldEnabled".as_ptr() + ]; + let off: *mut Object = msg_send![class!(NSNumber), numberWithBool: NO]; + let defaults: *mut Object = + msg_send![class!(NSDictionary), dictionaryWithObject: off forKey: key]; + let store: *mut Object = msg_send![class!(NSUserDefaults), standardUserDefaults]; + let _: () = msg_send![store, registerDefaults: defaults]; + } + log::info!("macOS: held keys repeat"); +} diff --git a/src/term/mod.rs b/src/term/mod.rs index 0d0bc89..593866c 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -1,6 +1,10 @@ mod boxes; pub mod frame; pub mod ink; +pub mod input; +pub mod keys; +#[cfg(target_os = "macos")] +pub mod mac; mod paint; mod pty; pub mod shell; diff --git a/src/term/paint.rs b/src/term/paint.rs index 6fc9e1e..55382c7 100644 --- a/src/term/paint.rs +++ b/src/term/paint.rs @@ -2,9 +2,9 @@ use std::rc::Rc; use alacritty_terminal::vte::ansi::CursorShape; use gpui::{ - Bounds, ContentMask, Entity, Font, FontId, FontStyle, FontWeight, Hsla, IntoElement, - PathBuilder, Pixels, Point, Size, StrikethroughStyle, Styled, TextRun, UnderlineStyle, Window, - canvas, fill, outline, point, px, size, + Bounds, ContentMask, ElementInputHandler, Entity, Font, FontId, FontStyle, FontWeight, Hsla, + IntoElement, PathBuilder, Pixels, Point, Size, StrikethroughStyle, Styled, TextRun, + UnderlineStyle, Window, canvas, fill, outline, point, px, size, }; use super::{ @@ -28,48 +28,101 @@ pub struct Look { pub rule: Pixels, pub cursor: Hsla, pub cursor_text: Hsla, + /// The terminal's own text and ground, for text an input method composes. + pub fg: Hsla, + pub bg: Hsla, } -/// The grid fitted to its box: it sizes the pty, then paints each row and the cursor. +/// The grid fitted to its box: it sizes the pty, then paints each row, the cursor and composed text. pub fn grid( view: Entity, frame: Rc, cursor: Option<(Cursor, CursorShape)>, + preedit: Option, look: Look, ) -> impl IntoElement { let cell = look.cell; + let (fit, input) = (view.clone(), view); + let at = frame.caret; canvas( move |bounds, window, cx| { let columns = (bounds.size.width / cell.width).floor() as usize; let lines = (bounds.size.height / cell.height).floor() as usize; - view.update(cx, |view, cx| { + let origin = snapped(bounds.origin, window); + fit.update(cx, |view, cx| { + if let Some((row, column)) = at { + let corner = + origin + point(cell.width * column as f32, cell.height * row as f32); + view.caret = Bounds::new(corner, cell); + } if view.fit(columns, lines) { cx.notify(); window.request_animation_frame(); } }); }, - move |bounds, _, window, _| { + move |bounds, _, window, cx| { log::trace!( "terminal: paint {:?}, row 0 {:?}", bounds, frame.rows.first().map(|row| row.text.trim_end()) ); - let scale = window.scale_factor(); - let snap = |length: Pixels| px((f32::from(length) * scale).round() / scale); - let origin = point(snap(bounds.origin.x), snap(bounds.origin.y)); + let focus = input.read(cx).focus.clone(); + window.handle_input(&focus, ElementInputHandler::new(bounds, input.clone()), cx); + let origin = snapped(bounds.origin, window); for (index, row) in frame.rows.iter().enumerate() { let top = origin + point(px(0.), cell.height * index as f32); paint_row(row, top, &look, window); } - if let Some((cursor, shape)) = &cursor { - paint_cursor(cursor, *shape, origin, &look, window); + match (&preedit, at, &cursor) { + (Some(text), Some(at), _) => paint_preedit(text, at, origin, &look, window), + (None, _, Some((cursor, shape))) => { + paint_cursor(cursor, *shape, origin, &look, window) + } + _ => {} } }, ) .size_full() } +/// `origin` on a whole device pixel. +fn snapped(origin: Point, window: &Window) -> Point { + let scale = window.scale_factor(); + let snap = |length: Pixels| px((f32::from(length) * scale).round() / scale); + point(snap(origin.x), snap(origin.y)) +} + +/// Text an input method composes, over the cursor's cell on the terminal's ground, underlined. +fn paint_preedit( + text: &str, + (row, column): (usize, usize), + origin: Point, + look: &Look, + window: &mut Window, +) { + let cell = look.cell; + let corner = origin + point(cell.width * column as f32, cell.height * row as f32); + let style = Style { + fg: look.fg, + bg: None, + bold: false, + italic: false, + underline: Underline::Single, + underline_color: look.fg, + strike: false, + }; + let runs = runs(text, &style, look.fg, look, window); + let width = window + .text_system() + .shape_line(text.to_string().into(), look.size, &runs, None) + .width + .max(cell.width); + window.paint_quad(fill(Bounds::new(corner, size(width, cell.height)), look.bg)); + draw(text, &runs, corner, None, look, window); + decorate(corner, width, &style, look, window); +} + fn face(style: &Style, base: &Font) -> Font { Font { weight: if style.bold { diff --git a/src/term/view.rs b/src/term/view.rs index d944c38..67c7209 100644 --- a/src/term/view.rs +++ b/src/term/view.rs @@ -3,14 +3,15 @@ use std::{path::PathBuf, process::ExitStatus, rc::Rc, time::Duration}; use alacritty_terminal::{event::Event, grid::Dimensions, vte::ansi::CursorShape}; use futures::StreamExt; use gpui::{ - ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontStyle, - FontWeight, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, Task, Window, - div, prelude::*, px, size, + Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, + FontStyle, FontWeight, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, Task, + Window, div, prelude::*, px, size, }; use super::{ frame::frame, ink::Ink, + input::{self, SendBackTab, SendTab}, paint::{self, Look}, pty::{Events, Pty, PtyEvent, config, fit}, }; @@ -39,8 +40,12 @@ pub enum TerminalEvent { /// A program on a pseudo-terminal, drawn cell by cell in the theme and font the settings choose. pub struct TerminalView { - pty: Pty, - focus: FocusHandle, + pub(super) pty: Pty, + pub(super) focus: FocusHandle, + /// Text an input method is composing, drawn at the cursor. + pub(super) marked: Option, + /// The cursor's cell in the window, from the last layout. + pub(super) caret: Bounds, size: (usize, usize), cell: (u16, u16), blinking: bool, @@ -69,6 +74,8 @@ impl TerminalView { Self { pty, focus: cx.focus_handle().tab_stop(true), + marked: None, + caret: Bounds::default(), size, cell: (8, 16), blinking: false, @@ -218,6 +225,8 @@ fn look(settings: &Settings, ink: &Ink, window: &Window) -> anyhow::Result font, cursor: ink.cursor, cursor_text: ink.cursor_text, + fg: ink.fg, + bg: ink.bg, }) } @@ -248,8 +257,12 @@ impl Render for TerminalView { }); let root = div() .id(("terminal", cx.entity_id())) - .key_context("Terminal") + .key_context(input::CONTEXT) .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)) @@ -260,7 +273,14 @@ impl Render for TerminalView { f32::from(look.cell.width).round() as u16, f32::from(look.cell.height).round() as u16, ); - root.child(paint::grid(cx.entity(), Rc::new(shown), cursor, look)) + let preedit = self.marked.clone(); + root.child(paint::grid( + cx.entity(), + Rc::new(shown), + cursor, + preedit, + look, + )) } Err(error) => { log::error!("terminal {}: {error:#}", self.pty.pid);