From 52b37cc2f80ddcffee481400f4e27501d8e26e07 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Thu, 1 Oct 2026 13:32:23 +0200 Subject: [PATCH] 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 --- src/kitty_keyboard.rs | 697 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 5 + src/terminal_box.rs | 108 ++++++- 3 files changed, 808 insertions(+), 2 deletions(-) create mode 100644 src/kitty_keyboard.rs diff --git a/src/kitty_keyboard.rs b/src/kitty_keyboard.rs new file mode 100644 index 0000000..df24628 --- /dev/null +++ b/src/kitty_keyboard.rs @@ -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 : . 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 { + 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> { + 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 = 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>>); + 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[ Result<(), Box> { // 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 diff --git a/src/terminal_box.rs b/src/terminal_box.rs index 1df9ae3..be73890 100644 --- a/src/terminal_box.rs +++ b/src/terminal_box.rs @@ -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 => {