diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 1c399a47c0633..3f3bde46af616 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -17,6 +17,7 @@ default = ["git"] unicode-lines = ["helix-core/unicode-lines", "helix-view/unicode-lines"] integration = ["helix-event/integration_test"] git = ["helix-vcs/git"] +scancode = ["helix-view/scancode"] [[bin]] name = "hx" @@ -72,6 +73,7 @@ serde = { version = "1.0", features = ["derive"] } grep-regex = "0.1.13" grep-searcher = "0.1.14" + [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } libc = "0.2.164" diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 61855d356d06c..469dc63011b4a 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -6252,6 +6252,8 @@ fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { let view = view.id; let doc = doc.id(); cx.on_next_key(move |cx, event| { + #[cfg(feature = "scancode")] + let event = cx.editor.scancode_apply(event); let alphabet = &cx.editor.config().jump_label_alphabet; let Some(i) = event .char() @@ -6268,6 +6270,8 @@ fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { } cx.on_next_key(move |cx, event| { doc_mut!(cx.editor, &doc).remove_jump_labels(view); + #[cfg(feature = "scancode")] + let event = cx.editor.scancode_apply(event); let alphabet = &cx.editor.config().jump_label_alphabet; let Some(inner) = event .char() diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index f7541fe25750b..7691b34a23191 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -941,6 +941,8 @@ impl EditorView { } fn command_mode(&mut self, mode: Mode, cxt: &mut commands::Context, event: KeyEvent) { + #[cfg(feature = "scancode")] + let event = cxt.editor.scancode_apply(event); match (event, cxt.editor.count) { // If the count is already started and the input is a number, always continue the count. (key!(i @ '0'..='9'), Some(count)) => { diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 6bbe1586855fc..06a10777da73d 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -14,6 +14,7 @@ homepage.workspace = true default = [] term = ["crossterm"] unicode-lines = [] +scancode = ["keyboard_query"] [dependencies] helix-stdx = { path = "../helix-stdx" } @@ -52,6 +53,8 @@ log = "~0.4" parking_lot = "0.12.3" thiserror.workspace = true +keyboard_query = { version = "0.1.0", optional = true } + [target.'cfg(windows)'.dependencies] clipboard-win = { version = "5.4", features = ["std"] } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 9e1bee8e14371..dff03b4ebc2e5 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -19,6 +19,10 @@ use helix_vcs::DiffProviderRegistry; use futures_util::stream::select_all::SelectAll; use futures_util::{future, StreamExt}; use helix_lsp::{Call, LanguageServerId}; + +#[cfg(feature = "scancode")] +use crate::scancode::{deserialize_scancode, KeyboardState, ScanCodeMap}; + use tokio_stream::wrappers::UnboundedReceiverStream; use std::{ @@ -350,6 +354,9 @@ pub struct Config { pub end_of_line_diagnostics: DiagnosticFilter, // Set to override the default clipboard provider pub clipboard_provider: ClipboardProvider, + #[cfg(feature = "scancode")] + #[serde(skip_serializing, deserialize_with = "deserialize_scancode")] + pub scancode: ScanCodeMap, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -917,6 +924,7 @@ impl From for LineEnding { LineEndingConfig::Crlf => LineEnding::Crlf, #[cfg(feature = "unicode-lines")] LineEndingConfig::FF => LineEnding::FF, + #[cfg(feature = "unicode-lines")] LineEndingConfig::CR => LineEnding::CR, #[cfg(feature = "unicode-lines")] @@ -989,6 +997,8 @@ impl Default for Config { inline_diagnostics: InlineDiagnosticsConfig::default(), end_of_line_diagnostics: DiagnosticFilter::Disable, clipboard_provider: ClipboardProvider::default(), + #[cfg(feature = "scancode")] + scancode: ScanCodeMap::default(), } } } @@ -1088,6 +1098,8 @@ pub struct Editor { pub mouse_down_range: Option, pub cursor_cache: CursorCache, + #[cfg(feature = "scancode")] + pub keyboard_state: KeyboardState, } pub type Motion = Box; @@ -1208,6 +1220,8 @@ impl Editor { handlers, mouse_down_range: None, cursor_cache: CursorCache::default(), + #[cfg(feature = "scancode")] + keyboard_state: KeyboardState::new(), } } @@ -2174,6 +2188,13 @@ impl Editor { current_view.id } } + + #[cfg(feature = "scancode")] + pub fn scancode_apply(&mut self, event: KeyEvent) -> KeyEvent { + self.config() + .scancode + .apply(event, &mut self.keyboard_state) + } } fn try_restore_indent(doc: &mut Document, view: &mut View) { diff --git a/helix-view/src/input.rs b/helix-view/src/input.rs index 5f5067eac9f83..04ca7412553d8 100644 --- a/helix-view/src/input.rs +++ b/helix-view/src/input.rs @@ -1,11 +1,10 @@ //! Input event handling, currently backed by crossterm. +pub use crate::keyboard::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode}; use anyhow::{anyhow, Error}; use helix_core::unicode::{segmentation::UnicodeSegmentation, width::UnicodeWidthStr}; use serde::de::{self, Deserialize, Deserializer}; use std::fmt; -pub use crate::keyboard::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode}; - #[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Hash)] pub enum Event { FocusGained, @@ -325,7 +324,43 @@ impl std::str::FromStr for KeyEvent { fn from_str(s: &str) -> Result { let mut tokens: Vec<_> = s.split('-').collect(); - let mut code = match tokens.pop().ok_or_else(|| anyhow!("Missing key code"))? { + let mut code = KeyCode::from_str(tokens.pop().ok_or_else(|| anyhow!("Missing key code"))?)?; + let mut modifiers = KeyModifiers::empty(); + for token in tokens { + let flag = match token { + "S" => KeyModifiers::SHIFT, + "A" => KeyModifiers::ALT, + "C" => KeyModifiers::CONTROL, + _ => return Err(anyhow!("Invalid key modifier '{}-'", token)), + }; + + if modifiers.contains(flag) { + return Err(anyhow!("Repeated key modifier '{}-'", token)); + } + modifiers.insert(flag); + } + + // Normalize character keys so that characters like C-S-r and C-R + // are represented by equal KeyEvents. + match code { + KeyCode::Char(ch) + if ch.is_ascii_lowercase() && modifiers.contains(KeyModifiers::SHIFT) => + { + code = KeyCode::Char(ch.to_ascii_uppercase()); + modifiers.remove(KeyModifiers::SHIFT); + } + _ => (), + } + + Ok(KeyEvent { code, modifiers }) + } +} + +impl std::str::FromStr for KeyCode { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(match s { keys::BACKSPACE => KeyCode::Backspace, keys::ENTER => KeyCode::Enter, keys::LEFT => KeyCode::Left, @@ -388,36 +423,7 @@ impl std::str::FromStr for KeyEvent { .ok_or_else(|| anyhow!("Invalid function key '{}'", function))? } invalid => return Err(anyhow!("Invalid key code '{}'", invalid)), - }; - - let mut modifiers = KeyModifiers::empty(); - for token in tokens { - let flag = match token { - "S" => KeyModifiers::SHIFT, - "A" => KeyModifiers::ALT, - "C" => KeyModifiers::CONTROL, - _ => return Err(anyhow!("Invalid key modifier '{}-'", token)), - }; - - if modifiers.contains(flag) { - return Err(anyhow!("Repeated key modifier '{}-'", token)); - } - modifiers.insert(flag); - } - - // Normalize character keys so that characters like C-S-r and C-R - // are represented by equal KeyEvents. - match code { - KeyCode::Char(ch) - if ch.is_ascii_lowercase() && modifiers.contains(KeyModifiers::SHIFT) => - { - code = KeyCode::Char(ch.to_ascii_uppercase()); - modifiers.remove(KeyModifiers::SHIFT); - } - _ => (), - } - - Ok(KeyEvent { code, modifiers }) + }) } } diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index d54b49ef54009..a3871dfb7fd4b 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -14,6 +14,8 @@ pub mod info; pub mod input; pub mod keyboard; pub mod register; +#[cfg(feature = "scancode")] +pub mod scancode; pub mod theme; pub mod tree; pub mod view; diff --git a/helix-view/src/scancode.rs b/helix-view/src/scancode.rs new file mode 100644 index 0000000000000..87a43b2e47a6e --- /dev/null +++ b/helix-view/src/scancode.rs @@ -0,0 +1,341 @@ +use crate::input::KeyEvent; +use crate::keyboard::{KeyCode, ModifierKeyCode}; +use anyhow; +use serde::{Deserialize, Deserializer}; +use std::collections::HashMap; + +use keyboard_query::{DeviceQuery, DeviceState}; + +type ScanCodeKeyCodeMap = HashMap)>; + +pub struct KeyboardState { + device_state: DeviceState, + previous_codes: Vec, +} + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub struct ScanCodeMap { + // {: {: (char, shifted char)}} + map: ScanCodeKeyCodeMap, + modifiers: Vec, + shift_modifiers: Vec, +} + +impl Default for KeyboardState { + fn default() -> Self { + Self::new() + } +} + +impl KeyboardState { + pub fn new() -> Self { + Self { + previous_codes: Vec::new(), + device_state: DeviceState::new(), + } + } + + pub fn get_keys(&mut self) -> (Vec, Vec) { + // detect new pressed keys to sync with crossterm sequential key parsing + let codes = self.device_state.get_keys(); + let new_codes = if codes.len() <= 1 { + codes.clone() + } else { + codes + .clone() + .into_iter() + .filter(|c| !self.previous_codes.contains(c)) + .collect() + }; + self.previous_codes = codes.clone(); + (codes, new_codes) + } +} + +impl ScanCodeMap { + pub fn new(map: HashMap)>) -> Self { + let modifiers = map + .iter() + .filter_map(|(code, (key, _))| { + if matches!(key, KeyCode::Modifier(_)) { + Some(*code) + } else { + None + } + }) + .collect(); + + let shift_modifiers = map + .iter() + .filter_map(|(code, (key, _))| { + if matches!( + key, + KeyCode::Modifier(ModifierKeyCode::LeftShift) + | KeyCode::Modifier(ModifierKeyCode::RightShift) + ) { + Some(*code) + } else { + None + } + }) + .collect(); + Self { + map, + modifiers, + shift_modifiers, + } + } + + pub fn apply(&self, event: KeyEvent, keyboard: &mut KeyboardState) -> KeyEvent { + let (scancodes, new_codes) = keyboard.get_keys(); + if new_codes.is_empty() { + return event; + } + + // get fist non modifier key code + let Some(scancode) = new_codes + .iter() + .find(|c| !self.modifiers.contains(c)) + .cloned() + else { + return event; + }; + + let Some((key, shifted_key)) = self.map.get(&scancode) else { + return event; + }; + + let event_before = event; + + let mut is_shifted = false; + for c in &self.shift_modifiers { + if scancodes.contains(c) { + is_shifted = true; + break; + } + } + + let event = KeyEvent { + code: match key { + KeyCode::Char(c) => { + if is_shifted | c.is_ascii_uppercase() { + (*shifted_key).unwrap_or(*key) + } else { + *key + } + } + _ => *key, + }, + ..event + }; + + log::trace!( + "Scancodes: {scancodes:?} Scancode: {scancode:?} (key: {key:?}, shifted key: {shifted_key:?}) Is shifted: {is_shifted} Event source {event_before:?} New Event {event:?}" + ); + + event + } +} + +pub fn deserialize_scancode<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + #[derive(Deserialize)] + struct ScanCodeRawConfig { + layout: String, + map: Option)>>>, + } + + let value = ScanCodeRawConfig::deserialize(deserializer)?; + + // load only specified in user settings layout + let map = if let Some(map) = value + .map + .and_then(|m| m.into_iter().find(|(k, _)| k == &value.layout)) + { + HashMap::from_iter( + map.1 + .into_iter() + .map(|(scancode, chars)| { + if chars.is_empty() { + anyhow::bail!( + "Invalid scancode. Empty map for scancode: {scancode} on layout: {}", + value.layout + ); + } + if chars.len() > 2 { + anyhow::bail!( + "Invalid scancode. To many variants for scancode: {scancode} on layout: {}", + value.layout + ); + } + let keycode = str::parse::(&chars[0]).map_err(|e| { + anyhow::anyhow!( + "On parse scancode: {scancode} on layout: {} - {e}", + value.layout + ) + })?; + let shifted_keycode = if let Some(c) = chars.get(1) { + Some(str::parse::(c).map_err(|e| { + anyhow::anyhow!( + "On parse scancode: {scancode} on layout: {} - {e}", + value.layout + ) + })?) + } else { + None + }; + Ok((scancode, (keycode, shifted_keycode))) + }) + .collect::>>() + .map_err(|e| ::custom(e))?, + ) + } else { + log::debug!("User defined scancode layout not found: {}", value.layout); + + // lookup in hardcoded defaults + let Some(map) = defaults::LAYOUTS.get(value.layout.as_str()) else { + return Err(::custom(format!( + "Scancode layout not found for: {}", + value.layout + ))); + }; + + map.to_owned() + }; + + Ok(ScanCodeMap::new(map)) +} + +mod defaults { + + use super::ScanCodeKeyCodeMap; + use crate::keyboard::KeyCode; + use std::collections::HashMap; + use std::str::FromStr; + + macro_rules! entry { + ($scancode:expr, $keycode:literal) => { + ( + $scancode, + ( + KeyCode::from_str($keycode).expect("Failed to parse {$keycode} as KeyCode"), + None, + ), + ) + }; + ($scancode:expr, $keycode:literal, $shifted_keycode:literal) => { + ( + $scancode, + ( + KeyCode::from_str($keycode).expect("Failed to parse {$keycode} as KeyCode"), + Some( + KeyCode::from_str($shifted_keycode) + .expect("Failed to parse {$shifted_keycode} as KeyCode"), + ), + ), + ) + }; + } + + pub static LAYOUTS: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(init); + + fn init() -> HashMap<&'static str, ScanCodeKeyCodeMap> { + HashMap::from_iter([qwerty()]) + } + + fn qwerty() -> (&'static str, ScanCodeKeyCodeMap) { + ( + "qwerty", + HashMap::from_iter([ + entry!(1, "esc"), + entry!(2, "1", "!"), + entry!(3, "2", "@"), + entry!(4, "3", "#"), + entry!(5, "4", "$"), + entry!(5, "4", "$"), + entry!(6, "5", "%"), + entry!(7, "6", "^"), + entry!(8, "7", "&"), + entry!(9, "8", "*"), + entry!(10, "9", "("), + entry!(11, "0", ")"), + entry!(12, "-", "_"), + entry!(13, "=", "+"), + entry!(14, "backspace"), + entry!(15, "tab"), + entry!(16, "q", "Q"), + entry!(17, "w", "W"), + entry!(18, "e", "E"), + entry!(19, "r", "R"), + entry!(20, "t", "T"), + entry!(21, "y", "Y"), + entry!(22, "u", "U"), + entry!(23, "i", "I"), + entry!(24, "o", "O"), + entry!(25, "p", "P"), + entry!(26, "[", "{"), + entry!(27, "]", "}"), + entry!(28, "ret"), + entry!(29, "leftcontrol"), + entry!(30, "a", "A"), + entry!(31, "s", "S"), + entry!(32, "d", "D"), + entry!(33, "f", "F"), + entry!(34, "g", "G"), + entry!(35, "h", "H"), + entry!(36, "j", "J"), + entry!(37, "k", "K"), + entry!(38, "l", "L"), + entry!(39, ";", ":"), + entry!(40, "'", "\""), + entry!(41, "`", "~"), + entry!(42, "leftshift"), + entry!(43, "\\", "|"), + entry!(44, "z", "Z"), + entry!(45, "x", "X"), + entry!(46, "c", "C"), + entry!(47, "v", "V"), + entry!(48, "b", "B"), + entry!(49, "n", "N"), + entry!(50, "m", "M"), + entry!(51, ",", "<"), + entry!(52, ".", ">"), + entry!(53, "/", "|"), + entry!(54, "rightshift"), + entry!(55, "printscreen"), + entry!(56, "leftalt"), + entry!(57, "space"), + entry!(58, "capslock"), + entry!(59, "F1"), + entry!(60, "F2"), + entry!(61, "F3"), + entry!(62, "F4"), + entry!(63, "F5"), + entry!(64, "F6"), + entry!(65, "F7"), + entry!(66, "F8"), + entry!(67, "F9"), + entry!(68, "F10"), + // entry!(69, "numlock"), + // entry!(70, "scrolllock"), + // entry!(71, "home"), + // entry!(72, "up"), + // entry!(73, "pageup"), + entry!(74, "-"), + // entry!(75, "left"), + // entry!(77, "right"), + entry!(78, "+"), + // entry!(79, "end"), + // entry!(80, "down"), + // entry!(81, "pagedown"), + // entry!(82, "ins"), + // entry!(83, "del"), + ]), + ) + } +}