yoda: protocole clavier kitty, Shift+Entree pour Claude Code et Modjo
Claude Code (>= 2.1.269) n'active Shift+Entree que si le terminal repond a CSI ? u. alacritty_terminal sait tenir la pile des modes et repondre, mais seulement avec kitty_keyboard, et n'encode aucune touche : Shift+ Entree partait en \r, identique a Entree. - term::Config : kitty_keyboard active. - kitty_keyboard : encodeur du frontal (spec kitty, decoupe de l'encodeur d'Alacritty), utilise seulement quand l'application au premier plan a pousse le drapeau 1 ou 8 ; sinon l'encodage legacy reste seul. Gere desambiguisation, types d'evenements (repetition, relachement), touches alternatives, toutes les touches, texte associe, pave numerique, F1-F24, touches de modification. Shift+Entree -> CSI 13;2u. - terminal_box : raccourcis de l'appli toujours prioritaires ; Shift+PageUp/PageDown/Home/End et Echap sur selection gardent leur comportement local ; relachements envoyes si le drapeau 2 est pose. - Legacy : Shift+Entree envoie LF (comme Ctrl+J) au lieu de CR. rustyline le lit comme Ctrl+J, la touche de saut de ligne de Modjo/Goose ; pour un shell c'est toujours Entree. Connu, non corrige ici : alacritty_terminal 0.25.1 push_keyboard_mode vide title_stack au lieu de la pile des modes au-dela de 4096 empilements (panique du fil PTY de l'onglet seulement, panic=unwind). 10 tests, dont la reponse a CSI ? u et l'application de CSI > 5 u. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2414aaece3
commit
52b37cc2f8
3 changed files with 808 additions and 2 deletions
697
src/kitty_keyboard.rs
Normal file
697
src/kitty_keyboard.rs
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
//! Protocole clavier kitty (yoda).
|
||||
//!
|
||||
//! Avec `kitty_keyboard` actif, alacritty_terminal tient la pile des modes
|
||||
//! (`CSI > n u`, `CSI < n u`, `CSI = n ; m u`) et répond à la requête
|
||||
//! `CSI ? u`. Il n'encode en revanche aucune touche : c'est le rôle du frontal,
|
||||
//! donc de ce module. Il ne sert que quand l'application au premier plan a
|
||||
//! demandé le protocole ; sinon l'encodage legacy de `terminal_box` s'applique
|
||||
//! seul.
|
||||
//!
|
||||
//! C'est ce qui donne Shift+Entrée (`CSI 13;2u`) à Claude Code, qui interroge
|
||||
//! le terminal avec `CSI ? u` au démarrage.
|
||||
//!
|
||||
//! Spécification : <https://sw.kovidgoyal.net/kitty/keyboard-protocol/>. La
|
||||
//! découpe suit l'encodeur du frontal Alacritty (`alacritty/src/input/keyboard.rs`).
|
||||
//!
|
||||
//! Limites : les drapeaux 2 (types d'événements), 4 (touches alternatives) et
|
||||
//! 16 (texte associé) ne sont honorés qu'avec le drapeau 1 (désambiguïsation)
|
||||
//! ou 8 (toutes les touches), ce que font toutes les applications connues.
|
||||
//! La touche de la disposition de base (3e champ du drapeau 4) n'est pas
|
||||
//! envoyée, et Caps Lock / Num Lock ne figurent pas dans les modificateurs
|
||||
//! (iced ne les expose pas).
|
||||
|
||||
use alacritty_terminal::term::TermMode;
|
||||
use cosmic::iced::keyboard::{Key, Location, Modifiers, key::Named};
|
||||
|
||||
/// Nature de l'événement clavier.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum KeyAction {
|
||||
Press,
|
||||
Repeat,
|
||||
Release,
|
||||
}
|
||||
|
||||
/// Événement clavier tel que livré par iced.
|
||||
pub struct KeyInput<'a> {
|
||||
/// Touche sans aucun modificateur (`key_without_modifiers` de winit).
|
||||
pub key: &'a Key,
|
||||
/// Touche avec Shift (et AltGr) appliqués, mais pas Ctrl (`logical_key`).
|
||||
pub modified_key: &'a Key,
|
||||
pub location: Location,
|
||||
pub modifiers: Modifiers,
|
||||
/// Texte produit, tous modificateurs appliqués.
|
||||
pub text: Option<&'a str>,
|
||||
pub action: KeyAction,
|
||||
}
|
||||
|
||||
const SHIFT: u8 = 1;
|
||||
const ALT: u8 = 2;
|
||||
const CTRL: u8 = 4;
|
||||
const SUPER: u8 = 8;
|
||||
|
||||
/// Le protocole kitty pilote-t-il l'encodage des touches ?
|
||||
pub fn active(mode: TermMode) -> bool {
|
||||
mode.intersects(TermMode::DISAMBIGUATE_ESC_CODES | TermMode::REPORT_ALL_KEYS_AS_ESC)
|
||||
}
|
||||
|
||||
/// Octets à envoyer au PTY quand [`active`] est vrai. Vide : rien à envoyer.
|
||||
pub fn encode(input: &KeyInput, mode: TermMode) -> Vec<u8> {
|
||||
let all = mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC);
|
||||
let mods = modifier_bits(input);
|
||||
let named = match input.modified_key {
|
||||
Key::Named(named) => Some(*named),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if input.action == KeyAction::Release {
|
||||
if !mode.contains(TermMode::REPORT_EVENT_TYPES) {
|
||||
return Vec::new();
|
||||
}
|
||||
// Pas de relâchement d'Entrée, Tab et Retour arrière hors « toutes les
|
||||
// touches » : le shell qui reprend la main après un plantage les
|
||||
// recevrait.
|
||||
if !all && matches!(named, Some(Named::Enter | Named::Tab | Named::Backspace)) {
|
||||
return Vec::new();
|
||||
}
|
||||
return build_sequence(input, mode, mods).unwrap_or_default();
|
||||
}
|
||||
|
||||
// Sans modificateur, Entrée, Tab et Retour arrière gardent leurs octets
|
||||
// legacy (spécification : un `reset` doit rester tapable). L'Entrée du
|
||||
// pavé numérique, elle, a son code dédié.
|
||||
if !all && mods == 0 && input.location != Location::Numpad {
|
||||
match named {
|
||||
Some(Named::Enter) => return b"\r".to_vec(),
|
||||
Some(Named::Tab) => return b"\t".to_vec(),
|
||||
Some(Named::Backspace) => return b"\x7f".to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let text = printable_text(input);
|
||||
let as_sequence = all
|
||||
|| text.is_none()
|
||||
|| named == Some(Named::Escape)
|
||||
|| (mods != 0 && mods != SHIFT)
|
||||
|| (mods == SHIFT && matches!(named, Some(Named::Enter | Named::Tab | Named::Backspace)));
|
||||
|
||||
if as_sequence {
|
||||
build_sequence(input, mode, mods).unwrap_or_default()
|
||||
} else {
|
||||
text.unwrap_or_default().as_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Texte imprimable produit par la touche, s'il y en a un.
|
||||
fn printable_text<'a>(input: &KeyInput<'a>) -> Option<&'a str> {
|
||||
input
|
||||
.text
|
||||
.filter(|text| !text.is_empty() && !text.chars().any(char::is_control))
|
||||
}
|
||||
|
||||
fn modifier_bits(input: &KeyInput) -> u8 {
|
||||
let mut bits = 0;
|
||||
if input.modifiers.shift() {
|
||||
bits |= SHIFT;
|
||||
}
|
||||
if input.modifiers.alt() {
|
||||
bits |= ALT;
|
||||
}
|
||||
if input.modifiers.control() {
|
||||
bits |= CTRL;
|
||||
}
|
||||
if input.modifiers.logo() {
|
||||
bits |= SUPER;
|
||||
}
|
||||
// Pour une touche de modification, l'état rapporté est celui qui suit
|
||||
// l'événement : le bit est posé à l'appui, retiré au relâchement.
|
||||
let own = match input.modified_key {
|
||||
Key::Named(Named::Shift) => SHIFT,
|
||||
Key::Named(Named::Alt) => ALT,
|
||||
Key::Named(Named::Control) => CTRL,
|
||||
Key::Named(Named::Super) => SUPER,
|
||||
_ => 0,
|
||||
};
|
||||
if input.action == KeyAction::Release {
|
||||
bits & !own
|
||||
} else {
|
||||
bits | own
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminateur d'une séquence : `u` pour les codes kitty, sinon le final
|
||||
/// legacy (`A`, `~`…) que le protocole conserve pour les touches de fonction.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Final {
|
||||
Kitty,
|
||||
Legacy(char),
|
||||
}
|
||||
|
||||
fn build_sequence(input: &KeyInput, mode: TermMode, mods: u8) -> Option<Vec<u8>> {
|
||||
let event_type = mode.contains(TermMode::REPORT_EVENT_TYPES)
|
||||
&& matches!(input.action, KeyAction::Repeat | KeyAction::Release);
|
||||
let associated_text = printable_text(input).filter(|_| {
|
||||
mode.contains(TermMode::REPORT_ASSOCIATED_TEXT) && input.action != KeyAction::Release
|
||||
});
|
||||
// Le paramètre par défaut vaut 1 : `CSI A` plutôt que `CSI 1 A`.
|
||||
let explicit = mods != 0 || event_type || associated_text.is_some();
|
||||
|
||||
let (payload, terminator) = numpad(input)
|
||||
.or_else(|| functional(input, explicit))
|
||||
.or_else(|| special(input, mode))
|
||||
.or_else(|| textual(input, mode, associated_text.is_some()))?;
|
||||
|
||||
let mut sequence = format!("\x1b[{payload}");
|
||||
if explicit {
|
||||
sequence.push_str(&format!(";{}", mods + 1));
|
||||
}
|
||||
if event_type {
|
||||
let kind = match input.action {
|
||||
KeyAction::Press => '1',
|
||||
KeyAction::Repeat => '2',
|
||||
KeyAction::Release => '3',
|
||||
};
|
||||
sequence.push(':');
|
||||
sequence.push(kind);
|
||||
}
|
||||
if let Some(text) = associated_text {
|
||||
let codepoints: Vec<String> = text.chars().map(|c| u32::from(c).to_string()).collect();
|
||||
sequence.push(';');
|
||||
sequence.push_str(&codepoints.join(":"));
|
||||
}
|
||||
sequence.push(match terminator {
|
||||
Final::Kitty => 'u',
|
||||
Final::Legacy(c) => c,
|
||||
});
|
||||
Some(sequence.into_bytes())
|
||||
}
|
||||
|
||||
/// Pavé numérique : codes kitty dédiés (57399 et suivants).
|
||||
fn numpad(input: &KeyInput) -> Option<(String, Final)> {
|
||||
if input.location != Location::Numpad {
|
||||
return None;
|
||||
}
|
||||
let code = match input.modified_key {
|
||||
Key::Character(c) => match c.as_str() {
|
||||
"0" => 57399,
|
||||
"1" => 57400,
|
||||
"2" => 57401,
|
||||
"3" => 57402,
|
||||
"4" => 57403,
|
||||
"5" => 57404,
|
||||
"6" => 57405,
|
||||
"7" => 57406,
|
||||
"8" => 57407,
|
||||
"9" => 57408,
|
||||
"." | "," => 57409,
|
||||
"/" => 57410,
|
||||
"*" => 57411,
|
||||
"-" => 57412,
|
||||
"+" => 57413,
|
||||
"=" => 57415,
|
||||
_ => return None,
|
||||
},
|
||||
Key::Named(named) => match named {
|
||||
Named::Enter => 57414,
|
||||
Named::ArrowLeft => 57417,
|
||||
Named::ArrowRight => 57418,
|
||||
Named::ArrowUp => 57419,
|
||||
Named::ArrowDown => 57420,
|
||||
Named::PageUp => 57421,
|
||||
Named::PageDown => 57422,
|
||||
Named::Home => 57423,
|
||||
Named::End => 57424,
|
||||
Named::Insert => 57425,
|
||||
Named::Delete => 57426,
|
||||
Named::Clear => 57427,
|
||||
_ => return None,
|
||||
},
|
||||
Key::Unidentified => return None,
|
||||
};
|
||||
Some((code.to_string(), Final::Kitty))
|
||||
}
|
||||
|
||||
/// Touches de fonction : finals legacy conservés par le protocole, sauf F3
|
||||
/// (`CSI R` serait pris pour un rapport de position du curseur) et F13+.
|
||||
fn functional(input: &KeyInput, explicit: bool) -> Option<(String, Final)> {
|
||||
let Key::Named(named) = input.modified_key else {
|
||||
return None;
|
||||
};
|
||||
let one = if explicit { "1" } else { "" };
|
||||
let (payload, terminator) = match named {
|
||||
Named::ArrowUp => (one, Final::Legacy('A')),
|
||||
Named::ArrowDown => (one, Final::Legacy('B')),
|
||||
Named::ArrowRight => (one, Final::Legacy('C')),
|
||||
Named::ArrowLeft => (one, Final::Legacy('D')),
|
||||
Named::End => (one, Final::Legacy('F')),
|
||||
Named::Home => (one, Final::Legacy('H')),
|
||||
Named::F1 => (one, Final::Legacy('P')),
|
||||
Named::F2 => (one, Final::Legacy('Q')),
|
||||
Named::F3 => ("13", Final::Legacy('~')),
|
||||
Named::F4 => (one, Final::Legacy('S')),
|
||||
Named::Insert => ("2", Final::Legacy('~')),
|
||||
Named::Delete => ("3", Final::Legacy('~')),
|
||||
Named::PageUp => ("5", Final::Legacy('~')),
|
||||
Named::PageDown => ("6", Final::Legacy('~')),
|
||||
Named::F5 => ("15", Final::Legacy('~')),
|
||||
Named::F6 => ("17", Final::Legacy('~')),
|
||||
Named::F7 => ("18", Final::Legacy('~')),
|
||||
Named::F8 => ("19", Final::Legacy('~')),
|
||||
Named::F9 => ("20", Final::Legacy('~')),
|
||||
Named::F10 => ("21", Final::Legacy('~')),
|
||||
Named::F11 => ("23", Final::Legacy('~')),
|
||||
Named::F12 => ("24", Final::Legacy('~')),
|
||||
_ => {
|
||||
let code = match named {
|
||||
Named::F13 => 57376,
|
||||
Named::F14 => 57377,
|
||||
Named::F15 => 57378,
|
||||
Named::F16 => 57379,
|
||||
Named::F17 => 57380,
|
||||
Named::F18 => 57381,
|
||||
Named::F19 => 57382,
|
||||
Named::F20 => 57383,
|
||||
Named::F21 => 57384,
|
||||
Named::F22 => 57385,
|
||||
Named::F23 => 57386,
|
||||
Named::F24 => 57387,
|
||||
Named::CapsLock => 57358,
|
||||
Named::ScrollLock => 57359,
|
||||
Named::NumLock => 57360,
|
||||
Named::PrintScreen => 57361,
|
||||
Named::Pause => 57362,
|
||||
Named::ContextMenu => 57363,
|
||||
Named::MediaPlay => 57428,
|
||||
Named::MediaPause => 57429,
|
||||
Named::MediaPlayPause => 57430,
|
||||
Named::MediaStop => 57432,
|
||||
Named::MediaFastForward => 57433,
|
||||
Named::MediaRewind => 57434,
|
||||
Named::MediaTrackNext => 57435,
|
||||
Named::MediaTrackPrevious => 57436,
|
||||
Named::MediaRecord => 57437,
|
||||
Named::AudioVolumeDown => 57438,
|
||||
Named::AudioVolumeUp => 57439,
|
||||
Named::AudioVolumeMute => 57440,
|
||||
_ => return None,
|
||||
};
|
||||
return Some((code.to_string(), Final::Kitty));
|
||||
}
|
||||
};
|
||||
Some((payload.to_string(), terminator))
|
||||
}
|
||||
|
||||
/// Touches de contrôle (Entrée, Tab, Échap, Retour arrière, Espace) et, avec
|
||||
/// « toutes les touches », les touches de modification elles-mêmes.
|
||||
fn special(input: &KeyInput, mode: TermMode) -> Option<(String, Final)> {
|
||||
let code = match input.modified_key {
|
||||
Key::Named(Named::Enter) => 13,
|
||||
Key::Named(Named::Tab) => 9,
|
||||
Key::Named(Named::Escape) => 27,
|
||||
Key::Named(Named::Backspace) => 127,
|
||||
Key::Character(c) if c == " " => 32,
|
||||
Key::Named(modifier) if mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) => {
|
||||
let right = input.location == Location::Right;
|
||||
match (modifier, right) {
|
||||
(Named::Shift, false) => 57441,
|
||||
(Named::Control, false) => 57442,
|
||||
(Named::Alt, false) => 57443,
|
||||
(Named::Super, false) => 57444,
|
||||
(Named::Hyper, false) => 57445,
|
||||
(Named::Meta, false) => 57446,
|
||||
(Named::Shift, true) => 57447,
|
||||
(Named::Control, true) => 57448,
|
||||
(Named::Alt, true) => 57449,
|
||||
(Named::Super, true) => 57450,
|
||||
(Named::Hyper, true) => 57451,
|
||||
(Named::Meta, true) => 57452,
|
||||
(Named::AltGraph, _) => 57453,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some((code.to_string(), Final::Kitty))
|
||||
}
|
||||
|
||||
/// Touches de caractère : code de la touche non décalée, suivi de la touche
|
||||
/// décalée si le drapeau 4 le demande (`CSI 97:65;6u` pour Ctrl+Shift+A).
|
||||
fn textual(input: &KeyInput, mode: TermMode, has_text: bool) -> Option<(String, Final)> {
|
||||
let Key::Character(shifted) = input.modified_key else {
|
||||
return None;
|
||||
};
|
||||
let mut chars = shifted.chars();
|
||||
let (Some(shifted_char), None) = (chars.next(), chars.next()) else {
|
||||
// Plusieurs caractères pour une touche : seul « toutes les touches »
|
||||
// avec texte associé sait le dire, par le code 0.
|
||||
return (mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) && has_text)
|
||||
.then(|| ("0".to_string(), Final::Kitty));
|
||||
};
|
||||
|
||||
let unshifted_char = if input.modifiers.shift() {
|
||||
shifted_char.to_lowercase().next().unwrap_or(shifted_char)
|
||||
} else {
|
||||
shifted_char
|
||||
};
|
||||
let mut code = u32::from(unshifted_char);
|
||||
let alternate = u32::from(shifted_char);
|
||||
// `!` n'a pas de minuscule : la touche de base est celle sans modificateur
|
||||
// (`1` en QWERTY ; `&` pour le `1` de l'AZERTY).
|
||||
if code == alternate
|
||||
&& let Key::Character(base) = input.key
|
||||
&& let Some(base_char) = base.chars().next()
|
||||
{
|
||||
code = u32::from(base_char);
|
||||
}
|
||||
|
||||
let payload = if mode.contains(TermMode::REPORT_ALTERNATE_KEYS) && alternate != code {
|
||||
format!("{code}:{alternate}")
|
||||
} else {
|
||||
code.to_string()
|
||||
};
|
||||
Some((payload, Final::Kitty))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DISAMBIGUATE: TermMode = TermMode::DISAMBIGUATE_ESC_CODES;
|
||||
|
||||
fn ch(c: &str) -> Key {
|
||||
Key::Character(c.into())
|
||||
}
|
||||
|
||||
fn named(n: Named) -> Key {
|
||||
Key::Named(n)
|
||||
}
|
||||
|
||||
fn press(
|
||||
key: Key,
|
||||
modified: Key,
|
||||
modifiers: Modifiers,
|
||||
text: Option<&str>,
|
||||
mode: TermMode,
|
||||
) -> String {
|
||||
event(key, modified, modifiers, text, KeyAction::Press, mode)
|
||||
}
|
||||
|
||||
fn event(
|
||||
key: Key,
|
||||
modified: Key,
|
||||
modifiers: Modifiers,
|
||||
text: Option<&str>,
|
||||
action: KeyAction,
|
||||
mode: TermMode,
|
||||
) -> String {
|
||||
let input = KeyInput {
|
||||
key: &key,
|
||||
modified_key: &modified,
|
||||
location: Location::Standard,
|
||||
modifiers,
|
||||
text,
|
||||
action,
|
||||
};
|
||||
String::from_utf8(encode(&input, mode)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_for_claude_code() {
|
||||
let enter = || named(Named::Enter);
|
||||
assert_eq!(
|
||||
press(enter(), enter(), Modifiers::SHIFT, Some("\r"), DISAMBIGUATE),
|
||||
"\x1b[13;2u"
|
||||
);
|
||||
assert_eq!(
|
||||
press(enter(), enter(), Modifiers::CTRL, Some("\r"), DISAMBIGUATE),
|
||||
"\x1b[13;5u"
|
||||
);
|
||||
// Entrée seule reste `\r`
|
||||
assert_eq!(
|
||||
press(enter(), enter(), Modifiers::empty(), Some("\r"), DISAMBIGUATE),
|
||||
"\r"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_tab_backspace() {
|
||||
let esc = || named(Named::Escape);
|
||||
assert_eq!(
|
||||
press(esc(), esc(), Modifiers::empty(), Some("\x1b"), DISAMBIGUATE),
|
||||
"\x1b[27u"
|
||||
);
|
||||
let tab = || named(Named::Tab);
|
||||
assert_eq!(
|
||||
press(tab(), tab(), Modifiers::empty(), Some("\t"), DISAMBIGUATE),
|
||||
"\t"
|
||||
);
|
||||
assert_eq!(
|
||||
press(tab(), tab(), Modifiers::SHIFT, Some("\t"), DISAMBIGUATE),
|
||||
"\x1b[9;2u"
|
||||
);
|
||||
let bs = || named(Named::Backspace);
|
||||
assert_eq!(
|
||||
press(bs(), bs(), Modifiers::empty(), Some("\x08"), DISAMBIGUATE),
|
||||
"\x7f"
|
||||
);
|
||||
assert_eq!(
|
||||
press(bs(), bs(), Modifiers::CTRL, Some("\x08"), DISAMBIGUATE),
|
||||
"\x1b[127;5u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keys() {
|
||||
// Texte simple ou décalé : envoyé tel quel
|
||||
assert_eq!(
|
||||
press(ch("a"), ch("a"), Modifiers::empty(), Some("a"), DISAMBIGUATE),
|
||||
"a"
|
||||
);
|
||||
assert_eq!(
|
||||
press(ch("a"), ch("A"), Modifiers::SHIFT, Some("A"), DISAMBIGUATE),
|
||||
"A"
|
||||
);
|
||||
assert_eq!(
|
||||
press(ch("é"), ch("é"), Modifiers::empty(), Some("é"), DISAMBIGUATE),
|
||||
"é"
|
||||
);
|
||||
// Ctrl et Alt : séquences
|
||||
assert_eq!(
|
||||
press(ch("c"), ch("c"), Modifiers::CTRL, Some("\x03"), DISAMBIGUATE),
|
||||
"\x1b[99;5u"
|
||||
);
|
||||
assert_eq!(
|
||||
press(ch("a"), ch("a"), Modifiers::ALT, Some("a"), DISAMBIGUATE),
|
||||
"\x1b[97;3u"
|
||||
);
|
||||
assert_eq!(
|
||||
press(ch(" "), ch(" "), Modifiers::CTRL, Some("\0"), DISAMBIGUATE),
|
||||
"\x1b[32;5u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_keys() {
|
||||
let mode = DISAMBIGUATE | TermMode::REPORT_ALTERNATE_KEYS;
|
||||
assert_eq!(
|
||||
press(
|
||||
ch("a"),
|
||||
ch("A"),
|
||||
Modifiers::CTRL | Modifiers::SHIFT,
|
||||
Some("\x01"),
|
||||
mode
|
||||
),
|
||||
"\x1b[97:65;6u"
|
||||
);
|
||||
// AZERTY : Ctrl+Shift+& donne « 1 »
|
||||
assert_eq!(
|
||||
press(
|
||||
ch("&"),
|
||||
ch("1"),
|
||||
Modifiers::CTRL | Modifiers::SHIFT,
|
||||
None,
|
||||
mode
|
||||
),
|
||||
"\x1b[38:49;6u"
|
||||
);
|
||||
// Sans le drapeau 4, pas d'alternative
|
||||
assert_eq!(
|
||||
press(
|
||||
ch("a"),
|
||||
ch("A"),
|
||||
Modifiers::CTRL | Modifiers::SHIFT,
|
||||
Some("\x01"),
|
||||
DISAMBIGUATE
|
||||
),
|
||||
"\x1b[97;6u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functional_keys() {
|
||||
let up = || named(Named::ArrowUp);
|
||||
assert_eq!(
|
||||
press(up(), up(), Modifiers::empty(), None, DISAMBIGUATE),
|
||||
"\x1b[A"
|
||||
);
|
||||
assert_eq!(
|
||||
press(up(), up(), Modifiers::CTRL, None, DISAMBIGUATE),
|
||||
"\x1b[1;5A"
|
||||
);
|
||||
let f3 = || named(Named::F3);
|
||||
assert_eq!(
|
||||
press(f3(), f3(), Modifiers::empty(), None, DISAMBIGUATE),
|
||||
"\x1b[13~"
|
||||
);
|
||||
let del = || named(Named::Delete);
|
||||
assert_eq!(
|
||||
press(del(), del(), Modifiers::SHIFT, None, DISAMBIGUATE),
|
||||
"\x1b[3;2~"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_types() {
|
||||
let mode = DISAMBIGUATE | TermMode::REPORT_EVENT_TYPES;
|
||||
assert_eq!(
|
||||
event(
|
||||
ch("a"),
|
||||
ch("a"),
|
||||
Modifiers::empty(),
|
||||
None,
|
||||
KeyAction::Release,
|
||||
mode
|
||||
),
|
||||
"\x1b[97;1:3u"
|
||||
);
|
||||
assert_eq!(
|
||||
event(
|
||||
ch("a"),
|
||||
ch("a"),
|
||||
Modifiers::CTRL,
|
||||
Some("\x01"),
|
||||
KeyAction::Repeat,
|
||||
mode
|
||||
),
|
||||
"\x1b[97;5:2u"
|
||||
);
|
||||
// Pas de relâchement d'Entrée hors « toutes les touches »
|
||||
let enter = || named(Named::Enter);
|
||||
assert_eq!(
|
||||
event(
|
||||
enter(),
|
||||
enter(),
|
||||
Modifiers::empty(),
|
||||
None,
|
||||
KeyAction::Release,
|
||||
mode
|
||||
),
|
||||
""
|
||||
);
|
||||
// Ni de relâchement du tout sans le drapeau 2
|
||||
assert_eq!(
|
||||
event(
|
||||
ch("a"),
|
||||
ch("a"),
|
||||
Modifiers::empty(),
|
||||
None,
|
||||
KeyAction::Release,
|
||||
DISAMBIGUATE
|
||||
),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_all_keys() {
|
||||
let mode = TermMode::REPORT_ALL_KEYS_AS_ESC | TermMode::REPORT_ASSOCIATED_TEXT;
|
||||
assert_eq!(
|
||||
press(ch("a"), ch("a"), Modifiers::empty(), Some("a"), mode),
|
||||
"\x1b[97;1;97u"
|
||||
);
|
||||
let enter = || named(Named::Enter);
|
||||
assert_eq!(
|
||||
press(enter(), enter(), Modifiers::empty(), Some("\r"), mode),
|
||||
"\x1b[13u"
|
||||
);
|
||||
// Touche de modification : son propre bit est posé à l'appui
|
||||
let shift = || named(Named::Shift);
|
||||
assert_eq!(
|
||||
press(shift(), shift(), Modifiers::empty(), None, mode),
|
||||
"\x1b[57441;2u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numpad_keys() {
|
||||
let input = |key: &Key, text| {
|
||||
String::from_utf8(encode(
|
||||
&KeyInput {
|
||||
key,
|
||||
modified_key: key,
|
||||
location: Location::Numpad,
|
||||
modifiers: Modifiers::empty(),
|
||||
text,
|
||||
action: KeyAction::Press,
|
||||
},
|
||||
DISAMBIGUATE,
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
// Un chiffre du pavé produit du texte : envoyé tel quel
|
||||
assert_eq!(input(&ch("5"), Some("5")), "5");
|
||||
// Entrée du pavé n'en produit pas d'imprimable : code dédié
|
||||
assert_eq!(input(&named(Named::Enter), Some("\r")), "\x1b[57414u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_with_disambiguate_or_all() {
|
||||
assert!(!active(TermMode::empty()));
|
||||
assert!(!active(TermMode::REPORT_EVENT_TYPES));
|
||||
assert!(active(DISAMBIGUATE));
|
||||
assert!(active(TermMode::REPORT_ALL_KEYS_AS_ESC));
|
||||
}
|
||||
|
||||
/// Le terminal répond à `CSI ? u` et applique `CSI > 5 u` dès que
|
||||
/// `kitty_keyboard` est actif : c'est ce que Claude Code interroge.
|
||||
#[test]
|
||||
fn term_reports_and_applies_kitty_modes() {
|
||||
use alacritty_terminal::{
|
||||
Term,
|
||||
event::{Event, EventListener},
|
||||
term::{Config, test::TermSize},
|
||||
vte::ansi::Processor,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Replies(Arc<Mutex<Vec<String>>>);
|
||||
impl EventListener for Replies {
|
||||
fn send_event(&self, event: Event) {
|
||||
if let Event::PtyWrite(text) = event {
|
||||
self.0.lock().unwrap().push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replies = Replies::default();
|
||||
let config = Config {
|
||||
kitty_keyboard: true,
|
||||
..Config::default()
|
||||
};
|
||||
let mut term = Term::new(config, &TermSize::new(80, 24), replies.clone());
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, b"\x1b[?u\x1b[>5u\x1b[?u");
|
||||
|
||||
assert_eq!(*replies.0.lock().unwrap(), ["\x1b[?0u", "\x1b[?5u"]);
|
||||
let mode = *term.mode();
|
||||
assert!(mode.contains(TermMode::DISAMBIGUATE_ESC_CODES));
|
||||
assert!(mode.contains(TermMode::REPORT_ALTERNATE_KEYS));
|
||||
assert!(active(mode));
|
||||
|
||||
parser.advance(&mut term, b"\x1b[<u");
|
||||
assert!(!active(*term.mode()));
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,8 @@ mod desktop_notify;
|
|||
use osc_tap::OscEvent;
|
||||
mod osc_tap;
|
||||
|
||||
mod kitty_keyboard;
|
||||
|
||||
use terminal_box::terminal_box;
|
||||
|
||||
use crate::dnd::DndDrop;
|
||||
|
|
@ -311,6 +313,9 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
// Terminal config setup
|
||||
let term_config = term::Config {
|
||||
scrolling_history: 100_000,
|
||||
// [yoda] Protocole clavier kitty : alacritty_terminal tient la pile des
|
||||
// modes et répond à `CSI ? u`, l'encodage est dans `kitty_keyboard`.
|
||||
kitty_keyboard: true,
|
||||
..term::Config::default()
|
||||
};
|
||||
// Set up environmental variables for terminal
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ use std::{
|
|||
};
|
||||
|
||||
use crate::{
|
||||
Action, Terminal, TerminalScroll,
|
||||
Action, Terminal, TerminalScroll, kitty_keyboard,
|
||||
menu::MenuState,
|
||||
terminal::{BlockFill, Metadata},
|
||||
};
|
||||
|
|
@ -940,6 +940,7 @@ where
|
|||
|
||||
let is_app_cursor = terminal.term.lock().mode().contains(TermMode::APP_CURSOR);
|
||||
let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE);
|
||||
let term_mode = *terminal.term.lock().mode();
|
||||
match event {
|
||||
Event::Window(event) => match event {
|
||||
cosmic::iced::window::Event::Focused => {
|
||||
|
|
@ -979,6 +980,101 @@ where
|
|||
}
|
||||
_ => {}
|
||||
},
|
||||
// [yoda] Protocole clavier kitty, quand l'application au premier plan
|
||||
// l'a demandé (voir `kitty_keyboard`) ; sinon encodage legacy plus bas.
|
||||
Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
physical_key,
|
||||
location,
|
||||
modifiers,
|
||||
text,
|
||||
repeat,
|
||||
}) if state.is_focused && kitty_keyboard::active(term_mode) => {
|
||||
for key_bind in self.key_binds.keys() {
|
||||
if key_bind.matches(*modifiers, key, Some(physical_key)) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Comportements locaux conservés, comme en legacy : Shift+PageUp…
|
||||
// fait défiler l'historique, Échap annule une sélection.
|
||||
let local_scroll = match modified_key {
|
||||
Key::Named(Named::PageUp) if modifiers.shift() => Some(TerminalScroll::PageUp),
|
||||
Key::Named(Named::PageDown) if modifiers.shift() => {
|
||||
Some(TerminalScroll::PageDown)
|
||||
}
|
||||
Key::Named(Named::Home) if modifiers.shift() => Some(TerminalScroll::Top),
|
||||
Key::Named(Named::End) if modifiers.shift() => Some(TerminalScroll::Bottom),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(scroll) = local_scroll {
|
||||
terminal.scroll(scroll);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if *modified_key == Key::Named(Named::Escape) {
|
||||
let had_selection = terminal.term.lock().selection.take().is_some();
|
||||
if had_selection {
|
||||
terminal.update();
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let input = kitty_keyboard::KeyInput {
|
||||
key,
|
||||
modified_key,
|
||||
location: *location,
|
||||
modifiers: *modifiers,
|
||||
text: text.as_deref(),
|
||||
action: if *repeat {
|
||||
kitty_keyboard::KeyAction::Repeat
|
||||
} else {
|
||||
kitty_keyboard::KeyAction::Press
|
||||
},
|
||||
};
|
||||
let bytes = kitty_keyboard::encode(&input, term_mode);
|
||||
if !bytes.is_empty() {
|
||||
// Une touche de modification seule (« toutes les touches »)
|
||||
// ne ramène pas la vue en bas.
|
||||
let modifier_only = matches!(
|
||||
modified_key,
|
||||
Key::Named(Named::Shift | Named::Control | Named::Alt | Named::Super)
|
||||
);
|
||||
if modifier_only {
|
||||
terminal.input_no_scroll(bytes);
|
||||
} else {
|
||||
terminal.input_scroll(bytes);
|
||||
}
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Event::Keyboard(KeyEvent::KeyReleased {
|
||||
key,
|
||||
modified_key,
|
||||
location,
|
||||
modifiers,
|
||||
..
|
||||
}) if state.is_focused
|
||||
&& kitty_keyboard::active(term_mode)
|
||||
&& term_mode.contains(TermMode::REPORT_EVENT_TYPES) =>
|
||||
{
|
||||
let input = kitty_keyboard::KeyInput {
|
||||
key,
|
||||
modified_key,
|
||||
location: *location,
|
||||
modifiers: *modifiers,
|
||||
text: None,
|
||||
action: kitty_keyboard::KeyAction::Release,
|
||||
};
|
||||
let bytes = kitty_keyboard::encode(&input, term_mode);
|
||||
if !bytes.is_empty() {
|
||||
terminal.input_no_scroll(bytes);
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(named),
|
||||
physical_key,
|
||||
|
|
@ -1094,7 +1190,15 @@ where
|
|||
shell.capture_event();
|
||||
}
|
||||
Named::Enter => {
|
||||
terminal.input_scroll(format!("{}{}", alt_prefix, "\x0D").into_bytes());
|
||||
// [yoda] Shift+Entrée envoie LF (`\n`, comme Ctrl+J) : saut de
|
||||
// ligne dans les éditeurs qui ne parlent pas le protocole kitty
|
||||
// (rustyline de Modjo/Goose) ; pour un shell, c'est Entrée.
|
||||
let code = if modifiers.shift() && !modifiers.control() {
|
||||
"\x0A"
|
||||
} else {
|
||||
"\x0D"
|
||||
};
|
||||
terminal.input_scroll(format!("{alt_prefix}{code}").into_bytes());
|
||||
shell.capture_event();
|
||||
}
|
||||
Named::Escape => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue