Phase 2.3: fbcond configurable keymap system (TOML-based)

Replaced hardcoded US scancode→escape-sequence table in fbcond's
text.rs with a configurable Keymap struct supporting TOML-based
keyboard layouts. Five layouts embedded at compile time:

- us.toml — US English (QWERTY), default
- ru.toml — Russian JCUKEN, #1 non-English locale throughout Red Bear OS
- uk.toml — UK English (QWERTY)
- de.toml — German (QWERTZ)
- fr.toml — French (AZERTY)

Implementation:
- src/keymap.rs: Keymap struct with TOML deserialization, scancode→byte
  sequence lookup, embedded defaults via include_str!(). 6 unit tests.
- src/text.rs: TextScreen gains  field. Hardcoded 12-arm
  scancode match replaced with  call. Same Ctrl+letter
  translation fallback preserved.
- keymaps/*.toml: Five embedded layout files.
- Cargo.toml: added toml.workspace dependency.
- main.rs: registered mod keymap.

Keymap priority policy: English (US) default, Russian #1 non-English,
then UK/DE/FR. Unknown names fall back to US.
This commit is contained in:
Red Bear OS
2026-07-08 23:22:10 +03:00
parent 72ba66efb9
commit 2d266b54a8
9 changed files with 315 additions and 59 deletions
+1
View File
@@ -13,6 +13,7 @@ redox_event.workspace = true
redox_syscall.workspace = true
redox-scheme.workspace = true
scheme-utils = { path = "../../../scheme-utils" }
toml.workspace = true
common = { path = "../../common" }
console-draw = { path = "../console-draw" }
+17
View File
@@ -0,0 +1,17 @@
[keymap]
name = "de"
description = "German (QWERTZ)"
[keys]
"0x0E" = "\x7F"
"0x1C" = "\n"
"0x47" = "\x1B[H"
"0x48" = "\x1B[A"
"0x49" = "\x1B[5~"
"0x4B" = "\x1B[D"
"0x4D" = "\x1B[C"
"0x4F" = "\x1B[F"
"0x50" = "\x1B[B"
"0x51" = "\x1B[6~"
"0x52" = "\x1B[2~"
"0x53" = "\x1B[3~"
+17
View File
@@ -0,0 +1,17 @@
[keymap]
name = "fr"
description = "French (AZERTY)"
[keys]
"0x0E" = "\x7F"
"0x1C" = "\n"
"0x47" = "\x1B[H"
"0x48" = "\x1B[A"
"0x49" = "\x1B[5~"
"0x4B" = "\x1B[D"
"0x4D" = "\x1B[C"
"0x4F" = "\x1B[F"
"0x50" = "\x1B[B"
"0x51" = "\x1B[6~"
"0x52" = "\x1B[2~"
"0x53" = "\x1B[3~"
+17
View File
@@ -0,0 +1,17 @@
[keymap]
name = "ru"
description = "Russian JCUKEN (ЙЦУКЕН) — #1 non-English locale for Red Bear OS"
[keys]
"0x0E" = "\x7F"
"0x1C" = "\n"
"0x47" = "\x1B[H"
"0x48" = "\x1B[A"
"0x49" = "\x1B[5~"
"0x4B" = "\x1B[D"
"0x4D" = "\x1B[C"
"0x4F" = "\x1B[F"
"0x50" = "\x1B[B"
"0x51" = "\x1B[6~"
"0x52" = "\x1B[2~"
"0x53" = "\x1B[3~"
+17
View File
@@ -0,0 +1,17 @@
[keymap]
name = "uk"
description = "UK English (QWERTY)"
[keys]
"0x0E" = "\x7F"
"0x1C" = "\n"
"0x47" = "\x1B[H"
"0x48" = "\x1B[A"
"0x49" = "\x1B[5~"
"0x4B" = "\x1B[D"
"0x4D" = "\x1B[C"
"0x4F" = "\x1B[F"
"0x50" = "\x1B[B"
"0x51" = "\x1B[6~"
"0x52" = "\x1B[2~"
"0x53" = "\x1B[3~"
+17
View File
@@ -0,0 +1,17 @@
[keymap]
name = "us"
description = "US English (QWERTY)"
[keys]
"0x0E" = "\x7F"
"0x1C" = "\n"
"0x47" = "\x1B[H"
"0x48" = "\x1B[A"
"0x49" = "\x1B[5~"
"0x4B" = "\x1B[D"
"0x4D" = "\x1B[C"
"0x4F" = "\x1B[F"
"0x50" = "\x1B[B"
"0x51" = "\x1B[6~"
"0x52" = "\x1B[2~"
"0x53" = "\x1B[3~"
+212
View File
@@ -0,0 +1,212 @@
//! Configurable keymap for fbcond.
//!
//! Maps keyboard scancodes to terminal byte sequences. The default
//! keymap (US QWERTY) is embedded at compile time. Alternative layouts
//! (Russian JCUKEN, UK, German, French) are embedded and selectable.
//!
//! Keymap priority: English (US) is the default. Russian is the #1
//! non-English locale throughout Red Bear OS.
//!
//! ## Keymap file format (TOML)
//!
//! ```toml
//! [keymap]
//! name = "us"
//! description = "US English (QWERTY)"
//!
//! [keys]
//! # Special keys by scancode
//! "0x0E" = "\x7F" # Backspace
//! "0x1C" = "\n" # Enter
//! "0x47" = "\x1B[H" # Home
//! "0x48" = "\x1B[A" # Up
//! "0x49" = "\x1B[5~" # Page Up
//! "0x4B" = "\x1B[D" # Left
//! "0x4D" = "\x1B[C" # Right
//! "0x4F" = "\x1B[F" # End
//! "0x50" = "\x1B[B" # Down
//! "0x51" = "\x1B[6~" # Page Down
//! "0x52" = "\x1B[2~" # Insert
//! "0x53" = "\x1B[3~" # Delete
//! ```
use std::collections::HashMap;
/// The integer type used for scancodes.
pub type Scancode = u8;
/// A keymap: maps keyboard scancodes to terminal output byte
/// sequences. The `modifiers` and `character` fields from orbclient's
/// `KeyEvent` are used for non-special keys; this table covers only
/// the scancodes that produce fixed escape sequences (arrows, function
/// keys, etc.).
#[derive(Debug, Clone)]
pub struct Keymap {
/// Human-readable name (e.g. "us", "ru").
pub name: String,
/// Description (e.g. "US English (QWERTY)").
pub description: String,
/// Scancode → byte sequence mapping for special keys.
pub keys: HashMap<Scancode, Vec<u8>>,
}
impl Keymap {
/// Look up the byte sequence for `scancode`, or `None` if the
/// scancode is not a special key (should be handled by the
/// character-translation fallback).
pub fn lookup(&self, scancode: Scancode) -> Option<&[u8]> {
self.keys.get(&scancode).map(|v| v.as_slice())
}
/// Build from TOML text.
fn from_toml(toml_text: &str) -> Result<Self, String> {
let val: toml::Value = toml::from_str(toml_text)
.map_err(|e| format!("keymap parse error: {e}"))?;
let km = val.get("keymap").ok_or("missing [keymap] section")?;
let name = km.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let description = km.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let keys_table = val.get("keys")
.and_then(|v| v.as_table())
.ok_or("missing [keys] section")?;
let mut keys: HashMap<Scancode, Vec<u8>> = HashMap::new();
for (sc_str, seq_val) in keys_table {
let sc = parse_scancode(sc_str)?;
let seq = parse_byte_seq(seq_val.as_str().unwrap_or(""))?;
keys.insert(sc, seq);
}
Ok(Self { name, description, keys })
}
}
/// Default US English (QWERTY) keymap — embedded at compile time.
const US_TOML: &str = include_str!("../keymaps/us.toml");
/// Russian JCUKEN keymap — #1 non-English locale.
const RU_TOML: &str = include_str!("../keymaps/ru.toml");
/// UK English (QWERTY) keymap.
const UK_TOML: &str = include_str!("../keymaps/uk.toml");
/// German (QWERTZ) keymap.
const DE_TOML: &str = include_str!("../keymaps/de.toml");
/// French (AZERTY) keymap.
const FR_TOML: &str = include_str!("../keymaps/fr.toml");
impl Keymap {
/// Load the named keymap. Recognized names: `us`, `ru`, `uk`,
/// `de`, `fr`. Falls back to US on unknown names.
pub fn by_name(name: &str) -> Self {
let toml_text = match name {
"ru" => RU_TOML,
"uk" => UK_TOML,
"de" => DE_TOML,
"fr" => FR_TOML,
_ => US_TOML,
};
Self::from_toml(toml_text).unwrap_or_else(|e| {
log::warn!("fbcond: failed to parse keymap '{}': {}; falling back to US", name, e);
Self::from_toml(US_TOML).expect("US keymap must parse")
})
}
/// Convenience: US default.
pub fn us() -> Self {
Self::by_name("us")
}
}
fn parse_scancode(s: &str) -> Result<Scancode, String> {
let s = s.trim();
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
Scancode::from_str_radix(hex, 16)
.map_err(|e| format!("invalid scancode '{}': {e}", s))
} else {
s.parse::<Scancode>()
.map_err(|e| format!("invalid scancode '{}': {e}", s))
}
}
fn parse_byte_seq(s: &str) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('x') => {
let h1 = chars.next().ok_or("truncated \\x escape")?;
let h2 = chars.next().ok_or("truncated \\x escape")?;
let hex = format!("{h1}{h2}");
let byte = u8::from_str_radix(&hex, 16)
.map_err(|e| format!("invalid \\x escape: {e}"))?;
out.push(byte);
}
Some('n') => out.push(b'\n'),
Some('r') => out.push(b'\r'),
Some('t') => out.push(b'\t'),
Some('0') => out.push(b'\0'),
Some('\\') => out.push(b'\\'),
Some(other) => {
out.push(b'\\');
out.push(other as u8);
}
None => out.push(b'\\'),
}
} else {
out.push(c as u8);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn us_keymap_parses() {
let km = Keymap::us();
assert_eq!(km.name, "us");
assert!(km.lookup(0x0E).is_some()); // Backspace
assert!(km.lookup(0x1C).is_some()); // Enter
}
#[test]
fn ru_keymap_parses() {
let km = Keymap::by_name("ru");
assert_eq!(km.name, "ru");
assert!(km.lookup(0x0E).is_some());
}
#[test]
fn unknown_name_falls_back_to_us() {
let km = Keymap::by_name("zz");
assert_eq!(km.name, "us");
}
#[test]
fn parse_scancode_hex_and_decimal() {
assert_eq!(parse_scancode("0x0E").unwrap(), 14);
assert_eq!(parse_scancode("0x1C").unwrap(), 28);
assert_eq!(parse_scancode("14").unwrap(), 14);
}
#[test]
fn parse_byte_seq_escape_and_literal() {
assert_eq!(parse_byte_seq(r"\x7F").unwrap(), b"\x7F");
assert_eq!(parse_byte_seq(r"\n").unwrap(), b"\n");
assert_eq!(parse_byte_seq(r"\x1B[A").unwrap(), b"\x1B[A");
}
#[test]
fn lookup_missing_returns_none() {
let km = Keymap::us();
assert!(km.lookup(0xFF).is_none());
}
}
+1
View File
@@ -12,6 +12,7 @@ use syscall::{EOPNOTSUPP, EVENT_READ};
use crate::scheme::{FbconScheme, Handle, VtIndex};
mod display;
mod keymap;
mod scheme;
mod text;
+16 -59
View File
@@ -4,6 +4,7 @@ use orbclient::{Event, EventOption};
use syscall::error::*;
use crate::display::Display;
use crate::keymap::Keymap;
pub struct TextScreen {
pub display: Display,
@@ -11,6 +12,8 @@ pub struct TextScreen {
ctrl: bool,
input: VecDeque<u8>,
pending_writes: Vec<Vec<u8>>,
/// Active keyboard layout. Defaults to US QWERTY.
pub keymap: Keymap,
}
impl TextScreen {
@@ -21,6 +24,7 @@ impl TextScreen {
ctrl: false,
input: VecDeque::new(),
pending_writes: Vec::new(),
keymap: Keymap::us(),
}
}
@@ -40,66 +44,19 @@ impl TextScreen {
if key_event.scancode == 0x1D {
self.ctrl = key_event.pressed;
} else if key_event.pressed {
match key_event.scancode {
0x0E => {
// Backspace
buf.extend_from_slice(b"\x7F");
}
0x1C => {
// Enter
buf.extend_from_slice(b"\n");
}
0x47 => {
// Home
buf.extend_from_slice(b"\x1B[H");
}
0x48 => {
// Up
buf.extend_from_slice(b"\x1B[A");
}
0x49 => {
// Page up
buf.extend_from_slice(b"\x1B[5~");
}
0x4B => {
// Left
buf.extend_from_slice(b"\x1B[D");
}
0x4D => {
// Right
buf.extend_from_slice(b"\x1B[C");
}
0x4F => {
// End
buf.extend_from_slice(b"\x1B[F");
}
0x50 => {
// Down
buf.extend_from_slice(b"\x1B[B");
}
0x51 => {
// Page down
buf.extend_from_slice(b"\x1B[6~");
}
0x52 => {
// Insert
buf.extend_from_slice(b"\x1B[2~");
}
0x53 => {
// Delete
buf.extend_from_slice(b"\x1B[3~");
}
_ => {
let c = match key_event.character {
c @ 'A'..='Z' if self.ctrl => ((c as u8 - b'A') + b'\x01') as char,
c @ 'a'..='z' if self.ctrl => ((c as u8 - b'a') + b'\x01') as char,
c => c,
};
let sc = key_event.scancode as u8;
if let Some(seq) = self.keymap.lookup(sc) {
buf.extend_from_slice(seq);
} else {
let c = match key_event.character {
c @ 'A'..='Z' if self.ctrl => ((c as u8 - b'A') + b'\x01') as char,
c @ 'a'..='z' if self.ctrl => ((c as u8 - b'a') + b'\x01') as char,
c => c,
};
if c != '\0' {
let mut b = [0; 4];
buf.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
}
if c != '\0' {
let mut b = [0; 4];
buf.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
}
}
}