mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #14 from l0ng-ai/feat/macos-option-as-alt
feat(input): treat Option as Alt/Meta on macOS behind a setting
This commit is contained in:
+13
-4
@@ -80,6 +80,13 @@ pub struct Config {
|
||||
pub cursor_style: CursorStyle,
|
||||
|
||||
// ── Input / Mouse ───────────────────────────────────────────────────────
|
||||
/// macOS only: treat the Option (⌥) key as Alt/Meta. On, an Option chord
|
||||
/// sends the ESC-prefixed sequence Meta bindings expect (Option+B → `ESC b`,
|
||||
/// readline's backward-word), like Ghostty's `macos-option-as-alt` /
|
||||
/// iTerm2's "Option as Meta". Off (the default), Option keeps its macOS
|
||||
/// role of composing special characters (Option+B → `∫`). Ignored on other
|
||||
/// platforms, where Alt always carries the Meta meaning.
|
||||
pub macos_option_as_alt: bool,
|
||||
/// Hide the OS mouse pointer while typing; it reappears on the next mouse
|
||||
/// move. Off by default.
|
||||
pub mouse_hide_while_typing: bool,
|
||||
@@ -244,10 +251,12 @@ impl Default for Config {
|
||||
// outdated is the status quo we're fixing. One cheap GET at startup.
|
||||
check_for_updates: true,
|
||||
cursor_style: CursorStyle::Block,
|
||||
// Input/mouse defaults preserve today's behavior: GPUI already hides
|
||||
// the pointer while typing (its `CursorHideMode` default), so this
|
||||
// starts `true`; no focus-follows-mouse, raw 1× scroll, no copy trim,
|
||||
// a normal centered window.
|
||||
// Input/mouse defaults preserve today's behavior: Option composes
|
||||
// characters as macOS ships it (opt into Option-as-Meta); GPUI
|
||||
// already hides the pointer while typing (its `CursorHideMode`
|
||||
// default), so that starts `true`; no focus-follows-mouse, raw 1×
|
||||
// scroll, no copy trim, a normal centered window.
|
||||
macos_option_as_alt: false,
|
||||
mouse_hide_while_typing: true,
|
||||
focus_follows_mouse: false,
|
||||
mouse_scroll_multiplier: 1.0,
|
||||
|
||||
+176
-1
@@ -41,6 +41,70 @@ impl KittyFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reshape a keystroke according to the macOS Option-key policy, before any
|
||||
/// encoding runs. macOS gives Option two jobs that a terminal can't serve at
|
||||
/// once: the OS composes a special character (Option+B types `∫`, delivered in
|
||||
/// `key_char`), while Meta bindings need an ESC-prefixed chord (Option+B →
|
||||
/// `ESC b`, readline's backward-word). `Config::macos_option_as_alt` picks:
|
||||
///
|
||||
/// * **On** — the chord is Meta: `key_char` is replaced with the plain key
|
||||
/// (uppercased under Shift, matching xterm's `metaSendsEscape` output), so
|
||||
/// the legacy encoder's Alt branch emits `ESC` + the base character instead
|
||||
/// of `ESC` + the composed one.
|
||||
/// * **Off** (default) — the chord is text input: the alt bit is dropped so the
|
||||
/// composed character is sent bare. (Without this, the legacy encoder bolts
|
||||
/// an ESC prefix onto the composed char — `ESC ∫` — a sequence that is wrong
|
||||
/// under either reading; and the prompt editor swallows the chord entirely.)
|
||||
///
|
||||
/// Only Option chords that produce a single text key are reshaped: named keys
|
||||
/// (arrows, Enter, …) and Ctrl/Cmd combinations keep their existing encodings
|
||||
/// on both settings. Returns `None` when the keystroke needs no reshaping, so
|
||||
/// callers only clone on the affected chords. Callers gate on macOS — the
|
||||
/// composed-character split doesn't exist elsewhere — but the function itself
|
||||
/// is platform-neutral so it can be tested everywhere.
|
||||
pub(super) fn reshape_option_keystroke(
|
||||
ks: &gpui::Keystroke,
|
||||
option_as_alt: bool,
|
||||
) -> Option<gpui::Keystroke> {
|
||||
let m = &ks.modifiers;
|
||||
if !m.alt || m.platform || m.control {
|
||||
return None;
|
||||
}
|
||||
if option_as_alt {
|
||||
// Meta semantics: the byte after ESC must be the key itself. Only
|
||||
// single-character keys compose; named keys already encode off `key`.
|
||||
let mut chars = ks.key.chars();
|
||||
let base = chars.next()?;
|
||||
if chars.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
// gpui reports shifted letters as a lowercase key + the shift bit;
|
||||
// Meta follows the shifted character (Option+Shift+B → `ESC B`).
|
||||
let ch = if m.shift {
|
||||
base.to_uppercase().to_string()
|
||||
} else {
|
||||
base.to_string()
|
||||
};
|
||||
if ks.key_char.as_deref() == Some(ch.as_str()) {
|
||||
return None; // already the base character — nothing to reshape
|
||||
}
|
||||
let mut out = ks.clone();
|
||||
out.key_char = Some(ch);
|
||||
Some(out)
|
||||
} else {
|
||||
// macOS convention: the chord is ordinary text input. A chord that
|
||||
// composed no printable text (named keys, Enter's "\n") stays a real
|
||||
// Alt chord — dropping alt there would break Alt+arrow and friends.
|
||||
let ch = ks.key_char.as_deref()?;
|
||||
if ch.is_empty() || ch.chars().any(|c| c < '\u{20}' || c == '\u{7f}') {
|
||||
return None;
|
||||
}
|
||||
let mut out = ks.clone();
|
||||
out.modifiers.alt = false;
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a GPUI keystroke into the bytes a PTY expects.
|
||||
///
|
||||
/// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try
|
||||
@@ -478,7 +542,7 @@ impl InputHandler for TerminalInputHandler {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{KittyFlags, keystroke_to_bytes, tab_bytes};
|
||||
use super::{KittyFlags, keystroke_to_bytes, reshape_option_keystroke, tab_bytes};
|
||||
use gpui::{Keystroke, Modifiers};
|
||||
|
||||
/// The legacy call shape used by the pre-existing tests: encode with the Kitty
|
||||
@@ -889,6 +953,117 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Encode through the Option-key policy the way `on_key_down` does: reshape
|
||||
/// first (macOS semantics), then hand the result to the shared encoder.
|
||||
fn reshaped_bytes(ks: &Keystroke, option_as_alt: bool, kitty: KittyFlags) -> Option<Vec<u8>> {
|
||||
let reshaped = reshape_option_keystroke(ks, option_as_alt);
|
||||
keystroke_to_bytes(reshaped.as_ref().unwrap_or(ks), kitty)
|
||||
}
|
||||
|
||||
/// An Option+B chord as gpui reports it on macOS: base key "b", the alt
|
||||
/// bit, and the OS-composed character in `key_char`.
|
||||
fn option_b() -> Keystroke {
|
||||
let alt = Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
ks(alt, "b", Some("∫"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_as_alt_on_sends_esc_plus_base_key() {
|
||||
// Meta semantics: ESC + the plain key, not ESC + the composed char.
|
||||
assert_eq!(
|
||||
reshaped_bytes(&option_b(), true, KittyFlags::default()),
|
||||
Some(b"\x1bb".to_vec())
|
||||
);
|
||||
// Shifted letters follow the shifted character: Option+Shift+B → ESC B.
|
||||
let alt_shift = Modifiers {
|
||||
alt: true,
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
reshaped_bytes(&ks(alt_shift, "b", Some("ı")), true, KittyFlags::default()),
|
||||
Some(b"\x1bB".to_vec())
|
||||
);
|
||||
// Non-letter keys too: Option+2 composes "™" but Meta sends ESC 2.
|
||||
let alt = Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
reshaped_bytes(&ks(alt, "2", Some("™")), true, KittyFlags::default()),
|
||||
Some(b"\x1b2".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_as_alt_off_sends_composed_text_bare() {
|
||||
// macOS convention: the chord is text input — the composed character
|
||||
// goes out with NO ESC prefix. (The unreshaped legacy path used to emit
|
||||
// `ESC ∫`, wrong under either reading of the Option key.)
|
||||
assert_eq!(
|
||||
reshaped_bytes(&option_b(), false, KittyFlags::default()),
|
||||
Some("∫".as_bytes().to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_reshape_leaves_named_keys_and_ctrl_chords_alone() {
|
||||
let alt = Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
// Named keys compose nothing: Alt+Up keeps its ESC-prefixed form on
|
||||
// both settings.
|
||||
for on in [true, false] {
|
||||
assert!(reshape_option_keystroke(&ks(alt, "up", None), on).is_none());
|
||||
assert_eq!(
|
||||
reshaped_bytes(&ks(alt, "up", None), on, KittyFlags::default()),
|
||||
Some(b"\x1b\x1b[A".to_vec())
|
||||
);
|
||||
}
|
||||
// Enter's key_char is a control char ("\n"), not composed text: the
|
||||
// chord stays a real Alt chord with the setting off.
|
||||
assert!(reshape_option_keystroke(&ks(alt, "enter", Some("\n")), false).is_none());
|
||||
// Ctrl+Alt chords keep the C0 + Meta-ESC encoding on both settings.
|
||||
let ctrl_alt = Modifiers {
|
||||
control: true,
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
for on in [true, false] {
|
||||
assert!(reshape_option_keystroke(&ks(ctrl_alt, "c", None), on).is_none());
|
||||
assert_eq!(
|
||||
reshaped_bytes(&ks(ctrl_alt, "c", None), on, KittyFlags::default()),
|
||||
Some(vec![0x1b, 0x03])
|
||||
);
|
||||
}
|
||||
// No alt held → nothing to reshape, either setting.
|
||||
assert!(
|
||||
reshape_option_keystroke(&ks(Modifiers::default(), "a", Some("a")), true).is_none()
|
||||
);
|
||||
// A key_char already equal to the base key needs no clone.
|
||||
assert!(reshape_option_keystroke(&ks(alt, "b", Some("b")), true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_reshape_composes_with_the_kitty_encoder() {
|
||||
// Option-as-Meta keeps the alt bit, so a Kitty-aware app still sees the
|
||||
// spec's alt-modified base key.
|
||||
assert_eq!(
|
||||
reshaped_bytes(&option_b(), true, kitty()),
|
||||
Some(b"\x1b[98;3u".to_vec())
|
||||
);
|
||||
// Option-as-composed drops the alt bit: at the disambiguate level the
|
||||
// chord is plain text, sent raw like any other typed character.
|
||||
assert_eq!(
|
||||
reshaped_bytes(&option_b(), false, kitty()),
|
||||
Some("∫".as_bytes().to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_never_encodes_cmd_chords() {
|
||||
// Cmd (platform) chords stay app-shortcut territory even with Kitty on:
|
||||
|
||||
+82
-1
@@ -740,7 +740,19 @@ impl TerminalView {
|
||||
if self.terminal.exited {
|
||||
return;
|
||||
}
|
||||
let ks = &ev.keystroke;
|
||||
// macOS Option-key policy (see `input::reshape_option_keystroke`):
|
||||
// reshape the chord once, up front, so every consumer below — the ⌘
|
||||
// dispatcher, the prompt editor, the raw PTY encoder — sees the same
|
||||
// story. Other platforms have no composed-character split to resolve.
|
||||
let reshaped = if cfg!(target_os = "macos") {
|
||||
super::input::reshape_option_keystroke(
|
||||
&ev.keystroke,
|
||||
cx.global::<Config>().macos_option_as_alt,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ks = reshaped.as_ref().unwrap_or(&ev.keystroke);
|
||||
let m = &ks.modifiers;
|
||||
|
||||
// While the search field is focused it owns the keyboard — typing, caret
|
||||
@@ -1077,6 +1089,36 @@ impl TerminalView {
|
||||
return;
|
||||
}
|
||||
|
||||
// Readline-style Meta word chords on the edited line: M-b / M-f motions
|
||||
// and M-d delete-word, mirroring the Alt+←/→/Delete handling below. On
|
||||
// macOS these are reachable only with `macos_option_as_alt` on — with it
|
||||
// off the chord composes a character upstream and arrives here altless,
|
||||
// through the printable-text arm. Other Alt+letter chords stay swallowed
|
||||
// no-ops as before (the local editor can't mirror every zle widget).
|
||||
if m.alt && !m.platform && !m.control {
|
||||
match key {
|
||||
"b" => {
|
||||
self.editor_move_h(false, m.shift, true);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
"f" => {
|
||||
self.editor_move_h(true, m.shift, true);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
"d" => {
|
||||
if !self.cmd.delete_selection() {
|
||||
self.cmd.delete_word_right();
|
||||
}
|
||||
self.history_nav = None;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match key {
|
||||
"enter" => {
|
||||
self.submit_command(cx);
|
||||
@@ -3933,6 +3975,45 @@ mod gpui_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Readline's Meta word chords act on the local prompt editor: M-b / M-f
|
||||
/// move by word, M-d deletes the word right of the caret. (On macOS these
|
||||
/// chords reach the editor only with `macos_option_as_alt` on — the
|
||||
/// `on_key_down` reshape otherwise strips the alt bit; here we drive the
|
||||
/// editor dispatcher directly with the post-reshape keystroke.)
|
||||
#[gpui::test]
|
||||
fn meta_word_chords_edit_the_prompt_line(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta = |key: &str| gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: key.to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.cmd.set("echo hello");
|
||||
// M-b from the end lands at the start of "hello".
|
||||
view.handle_editor_key(&meta("b"), cx);
|
||||
assert_eq!(view.cmd.cursor(), 5);
|
||||
// M-d deletes the word right of the caret.
|
||||
view.handle_editor_key(&meta("d"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo ");
|
||||
// M-b / M-f hop the remaining word: back to its start, then
|
||||
// forward to its end.
|
||||
view.handle_editor_key(&meta("b"), cx);
|
||||
assert_eq!(view.cmd.cursor(), 0);
|
||||
view.handle_editor_key(&meta("f"), cx);
|
||||
assert_eq!(view.cmd.cursor(), 4);
|
||||
// Other Meta letters stay swallowed no-ops (line untouched).
|
||||
view.handle_editor_key(&meta("z"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo ");
|
||||
assert_eq!(view.cmd.cursor(), 4);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A `PtyWrite` raised by the VT layer (query replies, bracketed-paste
|
||||
/// wrapping…) must come out of the client socket as an `Input` frame —
|
||||
/// this is the half of the query round-trip the remote tests can't see.
|
||||
|
||||
@@ -627,6 +627,12 @@ impl Tty7App {
|
||||
|
||||
// ── Input / Mouse setters ───────────────────────────────────────────────
|
||||
|
||||
/// Takes effect on the next keystroke — the terminal reads the flag per
|
||||
/// key event, so nothing needs pushing to open panes.
|
||||
pub(crate) fn set_macos_option_as_alt(&mut self, on: bool, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| cfg.macos_option_as_alt = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_mouse_hide_while_typing(&mut self, on: bool, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| cfg.mouse_hide_while_typing = on);
|
||||
// Push the new policy to GPUI right away (same call the hot-reload uses).
|
||||
|
||||
@@ -846,6 +846,7 @@ impl Tty7App {
|
||||
let link_url = cfg.link_url;
|
||||
let mouse_hide = cfg.mouse_hide_while_typing;
|
||||
let focus_follows = cfg.focus_follows_mouse;
|
||||
let option_as_alt = cfg.macos_option_as_alt;
|
||||
let scroll_mult = cfg.mouse_scroll_multiplier;
|
||||
let clip_trim = cfg.clipboard_trim_trailing_spaces;
|
||||
// Map the persisted scrollback depth onto its preset radio index (default
|
||||
@@ -912,6 +913,23 @@ impl Tty7App {
|
||||
.checked(clip_trim)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx)))
|
||||
.into_any_element();
|
||||
// macOS only: the Option/special-character split this toggle resolves
|
||||
// doesn't exist on other platforms, where Alt always carries Meta.
|
||||
let option_alt_row = cfg!(target_os = "macos").then(|| {
|
||||
let switch = Switch::new("term-option-as-alt")
|
||||
.checked(option_as_alt)
|
||||
.on_click(
|
||||
cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
self.settings_row(
|
||||
"Option (⌥) acts as Meta",
|
||||
"⌥+key sends the escape chord shells expect (⌥B = back one word) \
|
||||
instead of typing a special character (∫).",
|
||||
switch,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
// Slider + a live readout of the current multiplier beside it.
|
||||
let scroll_control = h_flex()
|
||||
.items_center()
|
||||
@@ -955,6 +973,11 @@ impl Tty7App {
|
||||
mouse_hide_switch,
|
||||
cx,
|
||||
))
|
||||
.when_some(option_alt_row, |v, row| {
|
||||
v.child(self.section_rule(cx))
|
||||
.child(self.section_header("Keyboard", cx))
|
||||
.child(row)
|
||||
})
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Links", cx))
|
||||
.child(self.settings_row(
|
||||
|
||||
Reference in New Issue
Block a user