diff --git a/eden/scm/lib/third-party/streampager/src/prompt.rs b/eden/scm/lib/third-party/streampager/src/prompt.rs index 14493dc897b54..55ba66cc85525 100644 --- a/eden/scm/lib/third-party/streampager/src/prompt.rs +++ b/eden/scm/lib/third-party/streampager/src/prompt.rs @@ -5,7 +5,7 @@ use std::fmt::Write; use termwiz::cell::{AttributeChange, CellAttributes}; use termwiz::color::{AnsiColor, ColorAttribute}; -use termwiz::input::KeyEvent; +use termwiz::input::{KeyCode, KeyEvent}; use termwiz::surface::change::Change; use termwiz::surface::Position; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -371,6 +371,13 @@ impl Prompt { const CTRL: Modifiers = Modifiers::CTRL; const NONE: Modifiers = Modifiers::NONE; const ALT: Modifiers = Modifiers::ALT; + // Strip SHIFT from Char keys (modifyOtherKeys=2 sends shifted char + SHIFT). + let mut key = key; + if key.modifiers.contains(Modifiers::SHIFT) { + if let KeyCode::Char(_) = key.key { + key.modifiers.remove(Modifiers::SHIFT); + } + } let value_width = width - self.prompt.width() - 4; let action = match (key.modifiers, key.key) { (NONE, Enter) | (CTRL, Char('j')) | (CTRL, Char('m')) => { diff --git a/eden/scm/lib/third-party/streampager/src/screen.rs b/eden/scm/lib/third-party/streampager/src/screen.rs index cea50925280c2..6b111e87bcc75 100644 --- a/eden/scm/lib/third-party/streampager/src/screen.rs +++ b/eden/scm/lib/third-party/streampager/src/screen.rs @@ -1241,19 +1241,40 @@ impl Screen { key: KeyEvent, event_sender: &EventSender, ) -> DisplayAction { - if let Some(binding) = self.keymap.get(key.modifiers, key.key) { + if let Some(binding) = Self::key_candidates(key) + .into_iter() + .find_map(|key| self.keymap.get(key.modifiers, key.key)) + { match binding { Binding::Action(action) => { let action = action.clone(); return self.dispatch_action(action, event_sender); } - Binding::Custom(b) => b.run(self.file.index()), + Binding::Custom(b) => { + b.run(self.file.index()); + return DisplayAction::Render; + } Binding::Unrecognized(_) => {} } } DisplayAction::Render } + fn key_candidates(key: KeyEvent) -> Vec { + use termwiz::input::{KeyCode, Modifiers}; + + let mut candidates = vec![key.clone()]; + if matches!(key.key, KeyCode::Char(_)) && key.modifiers.contains(Modifiers::SHIFT) { + let mut modifiers = key.modifiers; + modifiers.remove(Modifiers::SHIFT); + candidates.push(KeyEvent { + key: key.key, + modifiers, + }); + } + candidates + } + /// Append a digit to the repeat count. pub(crate) fn append_digit_to_repeat_count(&mut self, digit: usize) { assert!(digit < 10); @@ -1418,3 +1439,38 @@ impl Screen { self.file.set_needed_lines(needed_lines); } } + +#[cfg(test)] +mod tests { + use super::*; + use termwiz::input::{KeyCode, Modifiers}; + + #[test] + fn shifted_char_tries_exact_binding_before_unshifted_fallback() { + let key = KeyEvent { + key: KeyCode::Char('G'), + modifiers: Modifiers::SHIFT, + }; + + assert_eq!( + Screen::key_candidates(key) + .into_iter() + .map(|key| (key.modifiers, key.key)) + .collect::>(), + vec![ + (Modifiers::SHIFT, KeyCode::Char('G')), + (Modifiers::NONE, KeyCode::Char('G')), + ] + ); + } + + #[test] + fn shifted_non_char_has_no_unshifted_fallback() { + let key = KeyEvent { + key: KeyCode::UpArrow, + modifiers: Modifiers::SHIFT, + }; + + assert_eq!(Screen::key_candidates(key).len(), 1); + } +} diff --git a/eden/scm/sapling/curses_compat.py b/eden/scm/sapling/curses_compat.py index 182356cd87cd0..2f387d5a4c5e8 100644 --- a/eden/scm/sapling/curses_compat.py +++ b/eden/scm/sapling/curses_compat.py @@ -173,7 +173,13 @@ def getch(self): code = ord(ch) if modifiers == Modifiers.NONE: return code - if modifiers in [Modifiers.CTRL or Modifiers.RIGHT_CTRL]: + if modifiers & Modifiers.SHIFT: + # Ghostty with modifyOtherKeys=2 sends Shift+A as + # Char('a')+SHIFT instead of plain 'A' (0x41). + # Normalize to uppercase so crecord keybindings like + # 'A' (toggle all), 'G', 'J', 'H', 'F' work. + return ord(ch.upper()) + if modifiers & (Modifiers.CTRL | Modifiers.RIGHT_CTRL): # emulate curses behavior, ord(upper) & 0x1f return ord(ch.upper()) & 0x1F case {"Function": fn}: