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;
|
use osc_tap::OscEvent;
|
||||||
mod osc_tap;
|
mod osc_tap;
|
||||||
|
|
||||||
|
mod kitty_keyboard;
|
||||||
|
|
||||||
use terminal_box::terminal_box;
|
use terminal_box::terminal_box;
|
||||||
|
|
||||||
use crate::dnd::DndDrop;
|
use crate::dnd::DndDrop;
|
||||||
|
|
@ -311,6 +313,9 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
// Terminal config setup
|
// Terminal config setup
|
||||||
let term_config = term::Config {
|
let term_config = term::Config {
|
||||||
scrolling_history: 100_000,
|
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()
|
..term::Config::default()
|
||||||
};
|
};
|
||||||
// Set up environmental variables for terminal
|
// Set up environmental variables for terminal
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Action, Terminal, TerminalScroll,
|
Action, Terminal, TerminalScroll, kitty_keyboard,
|
||||||
menu::MenuState,
|
menu::MenuState,
|
||||||
terminal::{BlockFill, Metadata},
|
terminal::{BlockFill, Metadata},
|
||||||
};
|
};
|
||||||
|
|
@ -940,6 +940,7 @@ where
|
||||||
|
|
||||||
let is_app_cursor = terminal.term.lock().mode().contains(TermMode::APP_CURSOR);
|
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 is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE);
|
||||||
|
let term_mode = *terminal.term.lock().mode();
|
||||||
match event {
|
match event {
|
||||||
Event::Window(event) => match event {
|
Event::Window(event) => match event {
|
||||||
cosmic::iced::window::Event::Focused => {
|
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 {
|
Event::Keyboard(KeyEvent::KeyPressed {
|
||||||
key: Key::Named(named),
|
key: Key::Named(named),
|
||||||
physical_key,
|
physical_key,
|
||||||
|
|
@ -1094,7 +1190,15 @@ where
|
||||||
shell.capture_event();
|
shell.capture_event();
|
||||||
}
|
}
|
||||||
Named::Enter => {
|
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();
|
shell.capture_event();
|
||||||
}
|
}
|
||||||
Named::Escape => {
|
Named::Escape => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue