Skip to content

Commit

Permalink
implement keyboard layout remap for all modes but NOR
Browse files Browse the repository at this point in the history
Adds a config option to remap keys in a different keyboard layout into
English so that keybindings work even when switched into a non-English
keyboard layout.

The corresponding config option is `editor.layout-remap` which is a
list of dictionaries with two mandatory keys, `from` and `into`, which
specify the translation mapping, e.g.:

```toml
[[editor.layout-remap]]
from = 'йцукенгшщзхъЙЦУКЕНГШЩЗХЪ'
into = 'qwertyuiop[]QWERTYUIOP{}'
```

These can be repeated multiple times to facilitate specifying mappings
containing different types of quotes and other special characters
which may require escaping in TOML config.

This circumvents helix-editor#133 in a way that Helix still does not recognise
keypresses by their scan-codes but still allows for the non-English
layout users to operate Helix without switching the layout which can
be especially useful for writing documentation in their native language
with Helix.
  • Loading branch information
Ilya Novozhilov committed Dec 7, 2022
1 parent c4d7cde commit 537405b
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 5 deletions.
68 changes: 68 additions & 0 deletions helix-term/examples/translate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use helix_term::keymap::default;
use helix_view::document::Mode;
use helix_view::input::{KeyCode, KeyEvent};
use std::collections::HashMap;

const LANGMAP: [(&'static str, &'static str); 6] = [
(r#"йцукенгшщзхъ"#, r#"qwertyuiop[]"#),
(r#"ЙЦУКЕНГШЩЗХЪ"#, r#"QWERTYUIOP{}"#),
(r#"фывапролджэё"#, r#"asdfghjkl;'\"#),
(r#"ФЫВАПРОЛДЖЭЁ"#, r#"ASDFGHJKL:"|"#),
(r#"]ячсмитьбю/"#, r#"`zxcvbnm,./"#),
(r#"[ЯЧСМИТЬБЮ?"#, r#"~ZXCVBNM<>?"#),
];

fn translate<F>(ev: &KeyEvent, f: F) -> Option<KeyEvent>
where
F: Fn(char) -> Option<char>,
{
if let KeyCode::Char(c) = ev.code {
Some(KeyEvent {
code: KeyCode::Char(f(c)?),
modifiers: ev.modifiers.clone(),
})
} else {
None
}
}

fn main() {
let mut langmap = LANGMAP
.iter()
.map(|(ru, en)| ru.chars().zip(en.chars()))
.flatten()
.filter(|(en, ru)| en != ru)
.map(|(ru, en)| (en, ru))
.collect::<HashMap<_, _>>();

langmap
.iter()
.filter_map(|(en, ru)| langmap.contains_key(ru).then(|| *en))
.collect::<Vec<_>>()
.into_iter()
.for_each(|c| {
langmap.remove(&c);
});

let keymaps = default::default();
for mode in [Mode::Normal, Mode::Select] {
println!("[keys.{}]", mode);
keymaps[&mode].traverse_map(|keys, name| {
let tr_keys = keys
.iter()
.filter_map(|ev| translate(ev, |c| langmap.get(&c).map(|c| *c)))
.enumerate()
.map(|(i, ev)| {
if i == 0 {
ev.to_string()
} else {
format!("+{}", ev)
}
})
.collect::<String>();
if !tr_keys.is_empty() {
println!(r#"{:?} = "{}""#, tr_keys, name);
}
});
}
}
10 changes: 8 additions & 2 deletions helix-term/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::{
compositor::{Compositor, Event},
config::Config,
job::Jobs,
keymap::Keymaps,
keymap::{Keymaps, LayoutRemap},
ui::{self, overlay::overlayed},
};

Expand Down Expand Up @@ -157,7 +157,13 @@ impl Application {
let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
&config.keys
}));
let editor_view = Box::new(ui::EditorView::new(Keymaps::new(keys)));
let layout_remap = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
&config.editor.layout_remap
}));
let editor_view = Box::new(ui::EditorView::new(
Keymaps::new(keys),
LayoutRemap::new(layout_remap),
));
compositor.push(editor_view);

if args.load_tutor {
Expand Down
55 changes: 55 additions & 0 deletions helix-term/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,32 @@ impl Keymap {
res
}

pub fn traverse_map<F>(&self, f: F)
where
F: Fn(&Vec<KeyEvent>, String),
{
fn map_node<F>(node: &KeyTrie, keys: &mut Vec<KeyEvent>, f: &F)
where
F: Fn(&Vec<KeyEvent>, String),
{
match node {
KeyTrie::Leaf(cmd) => match cmd {
MappableCommand::Typable { name, .. } => f(keys, name.into()),
MappableCommand::Static { name, .. } => f(keys, (*name).to_owned()),
},
KeyTrie::Node(next) => {
for (key, trie) in &next.map {
keys.push(*key);
map_node(trie, keys, f);
keys.pop();
}
}
KeyTrie::Sequence(_) => {}
}
}
map_node(&self.root, &mut Vec::new(), &f);
}

pub fn root(&self) -> &KeyTrie {
&self.root
}
Expand Down Expand Up @@ -425,6 +451,35 @@ pub fn merge_keys(mut config: Config) -> Config {
config
}

pub struct LayoutRemap {
pub map: Box<dyn DynAccess<HashMap<char, char>>>,
}

impl LayoutRemap {
pub fn new(map: Box<dyn DynAccess<HashMap<char, char>>>) -> Self {
Self { map }
}

pub fn translate(&self, event: &KeyEvent) -> KeyEvent {
use helix_view::keyboard::KeyCode;
let remap = &*self.map.load();
if let KeyCode::Char(c) = event.code {
KeyEvent {
code: KeyCode::Char(*remap.get(&c).unwrap_or(&c)),
modifiers: event.modifiers,
}
} else {
*event
}
}
}

impl Default for LayoutRemap {
fn default() -> Self {
Self::new(Box::new(ArcSwap::new(Arc::new(HashMap::new()))))
}
}

#[cfg(test)]
mod tests {
use super::macros::keymap;
Expand Down
14 changes: 11 additions & 3 deletions helix-term/src/ui/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::{
compositor::{Component, Context, Event, EventResult},
job::{self, Callback},
key,
keymap::{KeymapResult, Keymaps},
keymap::{KeymapResult, Keymaps, LayoutRemap},
ui::{Completion, ProgressSpinners},
};

Expand Down Expand Up @@ -34,6 +34,7 @@ use super::statusline;

pub struct EditorView {
pub keymaps: Keymaps,
layout_remap: LayoutRemap,
on_next_key: Option<Box<dyn FnOnce(&mut commands::Context, KeyEvent)>>,
pseudo_pending: Vec<KeyEvent>,
last_insert: (commands::MappableCommand, Vec<InsertEvent>),
Expand All @@ -50,14 +51,15 @@ pub enum InsertEvent {

impl Default for EditorView {
fn default() -> Self {
Self::new(Keymaps::default())
Self::new(Keymaps::default(), LayoutRemap::default())
}
}

impl EditorView {
pub fn new(keymaps: Keymaps) -> Self {
pub fn new(keymaps: Keymaps, layout_remap: LayoutRemap) -> Self {
Self {
keymaps,
layout_remap,
on_next_key: None,
pseudo_pending: Vec::new(),
last_insert: (commands::MappableCommand::normal_mode, Vec::new()),
Expand Down Expand Up @@ -921,6 +923,12 @@ impl EditorView {
) -> Option<KeymapResult> {
let mut last_mode = mode;
self.pseudo_pending.extend(self.keymaps.pending());
// Translate Normal and Select mode keys using layout remap table.
let event = if !matches!(last_mode, Mode::Insert) {
self.layout_remap.translate(&event)
} else {
event
};
let key_result = self.keymaps.get(mode, event);
cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox());

Expand Down
64 changes: 64 additions & 0 deletions helix-view/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::{
use futures_util::stream::select_all::SelectAll;
use futures_util::{future, StreamExt};
use helix_lsp::Call;
use log::warn;
use tokio_stream::wrappers::UnboundedReceiverStream;

use std::{
Expand Down Expand Up @@ -67,6 +68,62 @@ where
)
}

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
struct LayoutMap {
from: String,
into: String,
}

impl Default for LayoutMap {
fn default() -> Self {
LayoutMap {
from: String::new(),
into: String::new(),
}
}
}

fn deserialize_layout_remap<'de, D>(deserializer: D) -> Result<HashMap<char, char>, D::Error>
where
D: serde::Deserializer<'de>,
{
// Make (from, to) char pairs from `Vec` of `LayoutMap`s filtering
// cases where `from == to` which we don't need to translate.
let mut pairs = Vec::<LayoutMap>::deserialize(deserializer)?
.iter()
.map(|layout_map| layout_map.from.chars().zip(layout_map.into.chars()))
.flatten()
.filter(|(from, to)| from != to)
.collect::<HashMap<char, char>>();
pairs
.iter()
.filter_map(|(_, to)| pairs.contains_key(to).then(|| *to))
.collect::<Vec<_>>()
.into_iter()
.for_each(|c| {
warn!(
"Key '{}' removed from layout-remap as it would overwrite Latin layout binding",
c
);
pairs.remove(&c);
});
Ok(pairs)
}

fn serialize_layout_remap<S>(map: &HashMap<char, char>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut from = String::new();
let mut into = String::new();
map.iter().for_each(|(f, t)| {
from.push(*f);
into.push(*t);
});
vec![LayoutMap { from, into }].serialize(serializer)
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
pub struct FilePickerConfig {
Expand Down Expand Up @@ -174,6 +231,12 @@ pub struct Config {
pub indent_guides: IndentGuidesConfig,
/// Whether to color modes with different colors. Defaults to `false`.
pub color_modes: bool,
/// Translate pressed keys in Normal and Select modes when a keyboard layout different from Latin is chosen in the system.
#[serde(
serialize_with = "serialize_layout_remap",
deserialize_with = "deserialize_layout_remap"
)]
pub layout_remap: HashMap<char, char>,
}

#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -610,6 +673,7 @@ impl Default for Config {
bufferline: BufferLine::default(),
indent_guides: IndentGuidesConfig::default(),
color_modes: false,
layout_remap: HashMap::new(),
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions helix-view/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ impl KeyEvent {
}
}

/// Translates a `KeyCode` using the `f` function. Only
/// `KeyCode::Char(c)` are translated. This method should be used
/// whenever a translation for the characters is desired, e.g. when
/// remapping a keyboard layout from non-Latin to Latin.
pub fn translate<F>(&self, f: F) -> KeyEvent
where
F: Fn(char) -> char,
{
if let KeyCode::Char(c) = self.code {
KeyEvent {
code: KeyCode::Char(f(c)),
modifiers: self.modifiers,
}
} else {
*self
}
}

/// Format the key in such a way that a concatenated sequence
/// of keys can be read easily.
///
Expand Down

0 comments on commit 537405b

Please sign in to comment.