T08: keys: xterm and kitty encoding, Option as Alt by side, IME composition at the cursor; shot types real AppKit keys
CI / platforms (windows-latest) (push) Failing after 9s
CI / macos (push) Failing after 8m31s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 14:22:52 -04:00
1 parent 9cecb85d67
commit 25365a98c8
15 files changed
+1949 -28

No files matched your search

+6 -1
View File
@@ -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),
+17 -1
View File
@@ -60,6 +60,8 @@ pub struct Cursor {
pub struct Frame {
pub rows: Vec<Row>,
pub cursor: Option<Cursor>,
/// 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<T: EventListener>(term: &Term<T>, 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");
}
+222
View File
@@ -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<u8>, cx: &mut Context<Self>) {
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<Self>,
) {
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<Self>) {
if self.send(&event.keystroke, Action::Release, cx) {
cx.stop_propagation();
}
}
pub(super) fn tab(&mut self, shift: bool, cx: &mut Context<Self>) {
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<Self>) -> 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<Self>) -> 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<usize>,
_: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
None
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
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<Self>) -> Option<Range<usize>> {
self.marked
.as_ref()
.map(|marked| 0..marked.encode_utf16().count())
}
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
log::debug!("terminal {}: composing ends", self.pty.pid);
self.marked = None;
cx.notify();
}
fn replace_text_in_range(
&mut self,
_: Option<Range<usize>>,
text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
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<Range<usize>>,
text: &str,
_: Option<Range<usize>>,
_: &mut Window,
cx: &mut Context<Self>,
) {
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<usize>,
_: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
Some(self.caret)
}
fn character_index_for_point(
&mut self,
_: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
None
}
}
+299
View File
@@ -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<Vec<u8>> {
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<char> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
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<Vec<u8>> {
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<String> = 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;
+260
View File
@@ -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<String> {
encode(&key, app_cursor, Kitty::default()).map(|bytes| String::from_utf8(bytes).expect("utf-8"))
}
fn kitty(key: Key, flags: Kitty) -> Option<String> {
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"));
}
+45
View File
@@ -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");
}
+4
View File
@@ -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;
+64 -11
View File
@@ -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<TerminalView>,
frame: Rc<Frame>,
cursor: Option<(Cursor, CursorShape)>,
preedit: Option<String>,
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<Pixels>, window: &Window) -> Point<Pixels> {
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<Pixels>,
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 {
+27 -7
View File
@@ -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<String>,
/// The cursor's cell in the window, from the last layout.
pub(super) caret: Bounds<Pixels>,
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<Look>
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);