use std::{collections::HashMap, env, path::PathBuf, sync::LazyLock}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use directories::ProjectDirs; use ratatui::style::{Color, Modifier, Style}; use serde::{Deserialize, Serialize, de::Deserializer}; use tracing::error; use crate::buffer::command::Command; /// MJB-LLR-180: the compiled-in default configuration is the very file the /// editor also reads at runtime, so defaults and documentation cannot drift. const CONFIG: &str = include_str!("../.config/config.toml"); /// Editor mode. Doubles as the scope key for both key bindings and styles. /// /// MJB-LLR-184. `Global` is consulted only by `App`, never by the buffer; see /// MJB-DR-004 for why keymap ownership is split. #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Mode { #[default] Normal, Insert, Select, Command, Global, } impl std::fmt::Display for Mode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let name = match self { Mode::Normal => "NOR", Mode::Insert => "INS", Mode::Select => "SEL", Mode::Command => "CMD", Mode::Global => "GLB", }; f.write_str(name) } } #[derive(Clone, Debug, Deserialize, Default)] pub struct AppConfig { #[serde(default)] pub data_dir: PathBuf, #[serde(default)] pub config_dir: PathBuf, } /// MJB-LLR-185. Kept in its own table rather than at top level: `Config` /// flattens `AppConfig`, and config-rs stringifies values buffered through a /// flattened map, which would break these non-string fields. #[derive(Clone, Copy, Debug, Deserialize)] pub struct EditorConfig { #[serde(default = "default_scrolloff")] pub scrolloff: usize, #[serde(default = "default_insert_final_newline")] pub insert_final_newline: bool, } fn default_scrolloff() -> usize { 5 } fn default_insert_final_newline() -> bool { true } impl Default for EditorConfig { fn default() -> Self { Self { scrolloff: default_scrolloff(), insert_final_newline: default_insert_final_newline(), } } } #[derive(Clone, Debug, Default, Deserialize)] pub struct Config { #[serde(default, flatten)] pub config: AppConfig, #[serde(default)] pub editor: EditorConfig, #[serde(default)] pub keybindings: KeyBindings, #[serde(default)] pub styles: Styles, } /// The application's own name, independent of the Cargo package name. /// /// Stated explicitly rather than derived from `CARGO_PKG_NAME`, which is /// `mojibake-editor` (the crate name on crates.io), or from /// `CARGO_CRATE_NAME`, which differs between the `mojibake` library target and /// the `moji` binary target. Deriving from either would silently move the /// user's configuration directory and log file when a target is renamed. pub const APP_NAME: &str = "mojibake"; pub static PROJECT_NAME: LazyLock = LazyLock::new(|| APP_NAME.to_uppercase()); pub static DATA_FOLDER: LazyLock> = LazyLock::new(|| { env::var(format!("{}_DATA", PROJECT_NAME.clone())) .ok() .map(PathBuf::from) }); pub static CONFIG_FOLDER: LazyLock> = LazyLock::new(|| { env::var(format!("{}_CONFIG", PROJECT_NAME.clone())) .ok() .map(PathBuf::from) }); impl Config { pub fn new() -> color_eyre::Result { // MJB-LLR-180. A malformed baked-in default is a build defect, but it // must still surface as an error rather than a panic (MJB-HLR-018). let default_config: Config = toml::from_str(CONFIG).map_err(|e| { config::ConfigError::Message(format!("built-in default config is invalid: {e}")) })?; let data_dir = get_data_dir(); let config_dir = get_config_dir(); let mut builder = config::Config::builder() .set_default("data_dir", path_to_setting(&data_dir)?)? .set_default("config_dir", path_to_setting(&config_dir)?)?; // MJB-LLR-181: TOML is the only format consulted. The json5/yaml/ini // readers are not merely unused here — `default-features = false` in // Cargo.toml keeps them out of the dependency graph entirely. let config_file = config_dir.join("config.toml"); if !config_file.exists() { error!( "No configuration file at {}; built-in defaults will be used", config_file.display() ); } builder = builder.add_source( config::File::from(config_file) .format(config::FileFormat::Toml) .required(false), ); let mut cfg: Self = builder.build()?.try_deserialize()?; // MJB-LLR-182: merge per individual binding, so a user who rebinds one // key keeps every default they did not mention. for (mode, default_bindings) in default_config.keybindings.0.iter() { let user_bindings = cfg.keybindings.0.entry(*mode).or_default(); for (key, cmd) in default_bindings.iter() { user_bindings.entry(key.clone()).or_insert(*cmd); } } for (mode, default_styles) in default_config.styles.0.iter() { let user_styles = cfg.styles.0.entry(*mode).or_default(); for (style_key, style) in default_styles.iter() { user_styles.entry(style_key.clone()).or_insert(*style); } } Ok(cfg) } /// The style named `key` for `mode`, or [`Style::default`] if unset. pub fn style(&self, mode: Mode, key: &str) -> Style { self.styles .0 .get(&mode) .and_then(|m| m.get(key)) .copied() .unwrap_or_default() } } /// A path becomes a config setting only if it is valid UTF-8. Reported rather /// than unwrapped, per MJB-HLR-018. fn path_to_setting(path: &std::path::Path) -> color_eyre::Result { path.to_str().map(str::to_owned).ok_or_else(|| { config::ConfigError::Message(format!("path is not valid UTF-8: {}", path.display())) }) } pub fn get_data_dir() -> PathBuf { if let Some(s) = DATA_FOLDER.clone() { s } else if let Some(proj_dirs) = project_directory() { proj_dirs.data_local_dir().to_path_buf() } else { PathBuf::from(".").join(".data") } } pub fn get_config_dir() -> PathBuf { if let Some(s) = CONFIG_FOLDER.clone() { s } else if let Some(proj_dirs) = project_directory() { proj_dirs.config_local_dir().to_path_buf() } else { PathBuf::from(".").join(".config") } } fn project_directory() -> Option { // The qualifier was the application template author's; mojibake owns its // own directories. `APP_NAME`, not the package name — see its doc comment. ProjectDirs::from("wiki", "mojibake", APP_NAME) } #[derive(Clone, Debug, Default)] pub struct KeyBindings(pub HashMap, Command>>); impl<'de> Deserialize<'de> for KeyBindings { fn deserialize(deserializer: D) -> color_eyre::Result where D: Deserializer<'de>, { let parsed_map = HashMap::>::deserialize(deserializer)?; let mut keybindings = HashMap::new(); for (mode, inner_map) in parsed_map { let mut converted = HashMap::new(); for (key_str, cmd) in inner_map { // MJB-LLR-183: a bad key string names itself in the error // rather than aborting the process. let keys = parse_key_sequence(&key_str).map_err(|e| { serde::de::Error::custom(format!( "invalid key sequence {key_str:?} in [keybindings.{mode:?}]: {e}" )) })?; converted.insert(keys, cmd); } keybindings.insert(mode, converted); } Ok(KeyBindings(keybindings)) } } fn parse_key_event(raw: &str) -> color_eyre::Result { // Modifier prefixes and named keys are matched case-insensitively, but the // final token's case is significant and must survive: `` and `` are // different bindings. Lowercasing the whole string collapsed them onto the // same `KeyCode::Char('a')`, so every shifted binding silently shadowed its // lowercase twin. // // `to_ascii_lowercase` preserves byte length, so an offset found in the // lowercased copy indexes the original correctly. let raw_lower = raw.to_ascii_lowercase(); let (offset, modifiers) = extract_modifiers(&raw_lower); parse_key_code_with_modifiers(&raw[offset..], &raw_lower[offset..], modifiers) } /// Returns the byte offset past the modifier prefixes, and the modifiers found. fn extract_modifiers(raw_lower: &str) -> (usize, KeyModifiers) { let mut modifiers = KeyModifiers::empty(); let mut offset = 0; loop { let rest = &raw_lower[offset..]; if rest.starts_with("ctrl-") { modifiers.insert(KeyModifiers::CONTROL); offset += 5; } else if rest.starts_with("alt-") { modifiers.insert(KeyModifiers::ALT); offset += 4; } else if rest.starts_with("shift-") { modifiers.insert(KeyModifiers::SHIFT); offset += 6; } else { break; } } (offset, modifiers) } fn parse_key_code_with_modifiers( raw: &str, raw_lower: &str, mut modifiers: KeyModifiers, ) -> color_eyre::Result { let c = match raw_lower { "esc" => KeyCode::Esc, "enter" => KeyCode::Enter, "left" => KeyCode::Left, "right" => KeyCode::Right, "up" => KeyCode::Up, "down" => KeyCode::Down, "home" => KeyCode::Home, "end" => KeyCode::End, "pageup" => KeyCode::PageUp, "pagedown" => KeyCode::PageDown, "backtab" => { modifiers.insert(KeyModifiers::SHIFT); KeyCode::BackTab } "backspace" => KeyCode::Backspace, "delete" => KeyCode::Delete, "insert" => KeyCode::Insert, "f1" => KeyCode::F(1), "f2" => KeyCode::F(2), "f3" => KeyCode::F(3), "f4" => KeyCode::F(4), "f5" => KeyCode::F(5), "f6" => KeyCode::F(6), "f7" => KeyCode::F(7), "f8" => KeyCode::F(8), "f9" => KeyCode::F(9), "f10" => KeyCode::F(10), "f11" => KeyCode::F(11), "f12" => KeyCode::F(12), "space" => KeyCode::Char(' '), "hyphen" => KeyCode::Char('-'), "minus" => KeyCode::Char('-'), "tab" => KeyCode::Tab, _ if raw.chars().count() == 1 => { // Case comes from the original token, not the lowercased copy. let mut c = raw.chars().next().ok_or("empty key")?; if modifiers.contains(KeyModifiers::SHIFT) { c = c.to_ascii_uppercase(); } else if c.is_ascii_uppercase() { // crossterm reports a capital as Char('A') with SHIFT held, so // `` must produce exactly that to ever match. modifiers.insert(KeyModifiers::SHIFT); } KeyCode::Char(c) } _ => return Err(format!("Unable to parse {raw}")), }; Ok(KeyEvent::new(c, modifiers)) } pub fn key_event_to_string(key_event: &KeyEvent) -> String { let char; let key_code = match key_event.code { KeyCode::Backspace => "backspace", KeyCode::Enter => "enter", KeyCode::Left => "left", KeyCode::Right => "right", KeyCode::Up => "up", KeyCode::Down => "down", KeyCode::Home => "home", KeyCode::End => "end", KeyCode::PageUp => "pageup", KeyCode::PageDown => "pagedown", KeyCode::Tab => "tab", KeyCode::BackTab => "backtab", KeyCode::Delete => "delete", KeyCode::Insert => "insert", KeyCode::F(c) => { char = format!("f({c})"); &char } KeyCode::Char(' ') => "space", KeyCode::Char(c) => { char = c.to_string(); &char } KeyCode::Esc => "esc", KeyCode::Null => "", KeyCode::CapsLock => "", KeyCode::Menu => "", KeyCode::ScrollLock => "", KeyCode::Media(_) => "", KeyCode::NumLock => "", KeyCode::PrintScreen => "", KeyCode::Pause => "", KeyCode::KeypadBegin => "", KeyCode::Modifier(_) => "", }; let mut modifiers = Vec::with_capacity(3); if key_event.modifiers.intersects(KeyModifiers::CONTROL) { modifiers.push("ctrl"); } if key_event.modifiers.intersects(KeyModifiers::SHIFT) { modifiers.push("shift"); } if key_event.modifiers.intersects(KeyModifiers::ALT) { modifiers.push("alt"); } let mut key = modifiers.join("-"); if !key.is_empty() { key.push('-'); } key.push_str(key_code); key } pub fn parse_key_sequence(raw: &str) -> color_eyre::Result, String> { if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { return Err(format!("Unable to parse `{}`", raw)); } let raw = if !raw.contains("><") { let raw = raw.strip_prefix('<').unwrap_or(raw); raw.strip_prefix('>').unwrap_or(raw) } else { raw }; let sequences = raw .split("><") .map(|seq| { if let Some(s) = seq.strip_prefix('<') { s } else if let Some(s) = seq.strip_suffix('>') { s } else { seq } }) .collect::>(); sequences.into_iter().map(parse_key_event).collect() } #[derive(Clone, Debug, Default)] pub struct Styles(pub HashMap>); impl<'de> Deserialize<'de> for Styles { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let parsed_map = HashMap::>::deserialize(deserializer)?; let styles = parsed_map .into_iter() .map(|(mode, inner_map)| { let converted_inner_map = inner_map .into_iter() .map(|(str, style)| (str, parse_style(&style))) .collect(); (mode, converted_inner_map) }) .collect(); Ok(Styles(styles)) } } pub fn parse_style(line: &str) -> Style { let (foreground, background) = line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); let foreground = process_color_string(foreground); let background = process_color_string(&background.replace("on ", "")); let mut style = Style::default(); if let Some(fg) = parse_color(&foreground.0) { style = style.fg(fg); } if let Some(bg) = parse_color(&background.0) { style = style.bg(bg); } style = style.add_modifier(foreground.1 | background.1); style } fn process_color_string(color_str: &str) -> (String, Modifier) { let color = color_str .replace("grey", "gray") .replace("bright ", "") .replace("bold ", "") .replace("underline ", "") .replace("inverse ", ""); let mut modifiers = Modifier::empty(); if color_str.contains("underline") { modifiers |= Modifier::UNDERLINED; } if color_str.contains("bold") { modifiers |= Modifier::BOLD; } if color_str.contains("inverse") { modifiers |= Modifier::REVERSED; } (color, modifiers) } fn parse_color(s: &str) -> Option { let s = s.trim_start(); let s = s.trim_end(); // Every arm below must tolerate arbitrary user input: these strings come // from `[styles]` in config.toml, and a panic here would take down the // editor at startup (MJB-HLR-018). The original template code indexed // `rgb` operands unchecked and added into `u8` unguarded, so `"rgb"`, // `"rgb1"`, `"gray99"` and `"rgb999"` all aborted the process. if s.contains("bright color") { let c = s .trim_start_matches("bright ") .trim_start_matches("color") .parse::() .unwrap_or_default(); // Bright ANSI colours are the base colour plus 8. The template wrote // `wrapping_shl(8)`, which on a `u8` masks the shift to 8 % 8 == 0 and // so returned the colour unchanged. Some(Color::Indexed(c.saturating_add(8))) } else if s.contains("color") { let c = s .trim_start_matches("color") .parse::() .unwrap_or_default(); Some(Color::Indexed(c)) } else if s.contains("gray") { // The xterm grayscale ramp is 24 steps at indices 232..=255. let step = s .trim_start_matches("gray") .parse::() .unwrap_or_default() .min(23); Some(Color::Indexed(232 + step)) } else if let Some(digits) = s.strip_prefix("rgb") { // The xterm 216-colour cube at indices 16..=231: three components, // each 0–5. `to_digit(6)` rejects anything outside that range, so the // arithmetic below cannot exceed 231. let mut components = digits.chars().map(|c| c.to_digit(6)); match (components.next(), components.next(), components.next()) { (Some(Some(r)), Some(Some(g)), Some(Some(b))) => { let index = 16 + r * 36 + g * 6 + b; debug_assert!(index <= 231); Some(Color::Indexed(index as u8)) } _ => None, } } else if s == "bold black" { Some(Color::Indexed(8)) } else if s == "bold red" { Some(Color::Indexed(9)) } else if s == "bold green" { Some(Color::Indexed(10)) } else if s == "bold yellow" { Some(Color::Indexed(11)) } else if s == "bold blue" { Some(Color::Indexed(12)) } else if s == "bold magenta" { Some(Color::Indexed(13)) } else if s == "bold cyan" { Some(Color::Indexed(14)) } else if s == "bold white" { Some(Color::Indexed(15)) } else if s == "black" { Some(Color::Indexed(0)) } else if s == "red" { Some(Color::Indexed(1)) } else if s == "green" { Some(Color::Indexed(2)) } else if s == "yellow" { Some(Color::Indexed(3)) } else if s == "blue" { Some(Color::Indexed(4)) } else if s == "magenta" { Some(Color::Indexed(5)) } else if s == "cyan" { Some(Color::Indexed(6)) } else if s == "white" { Some(Color::Indexed(7)) } else { None } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; #[test] fn test_parse_style_default() { let style = parse_style(""); assert_eq!(style, Style::default()); } #[test] fn test_parse_style_foreground() { let style = parse_style("red"); assert_eq!(style.fg, Some(Color::Indexed(1))); } #[test] fn test_parse_style_background() { let style = parse_style("on blue"); assert_eq!(style.bg, Some(Color::Indexed(4))); } #[test] fn test_parse_style_modifiers() { let style = parse_style("underline red on blue"); assert_eq!(style.fg, Some(Color::Indexed(1))); assert_eq!(style.bg, Some(Color::Indexed(4))); } #[test] fn test_process_color_string() { let (color, modifiers) = process_color_string("underline bold inverse gray"); assert_eq!(color, "gray"); assert!(modifiers.contains(Modifier::UNDERLINED)); assert!(modifiers.contains(Modifier::BOLD)); assert!(modifiers.contains(Modifier::REVERSED)); } #[test] fn test_parse_color_rgb() { let color = parse_color("rgb123"); let expected = 16 + 36 + 2 * 6 + 3; assert_eq!(color, Some(Color::Indexed(expected))); } #[test] fn test_parse_color_unknown() { let color = parse_color("unknown"); assert_eq!(color, None); } /// MJB-HLR-018: colour strings come from user configuration, so no input /// may abort the process. Regression guard — every case below panicked in /// the template code this replaced, by unchecked indexing or by `u8` /// overflow in a debug build. #[test] fn mjb_llr_183_malformed_colours_never_panic() { for input in [ "rgb", // indexed byte 3 of a 3-byte string "rgb1", // indexed bytes 4 and 5 "rgb12", // indexed byte 5 "rgb999", // 16 + 9*36 + 9*6 + 9 = 403, overflows u8 "rgb555", // the largest legal cube entry "gray99", // 232 + 99 = 331, overflows u8 "gray", // no digits at all "color999", // does not fit u8 "bright color999", "", "文字化け", // multi-byte: byte indexing would split a character ] { let _ = parse_color(input); let _ = parse_style(input); } } #[test] fn mjb_llr_183_colour_cube_bounds() { // 216-colour cube occupies 16..=231. assert_eq!(parse_color("rgb000"), Some(Color::Indexed(16))); assert_eq!(parse_color("rgb555"), Some(Color::Indexed(231))); // Components outside 0–5 are not cube coordinates. assert_eq!(parse_color("rgb600"), None); } #[test] fn mjb_llr_183_grayscale_ramp_bounds() { // Grayscale ramp occupies 232..=255. assert_eq!(parse_color("gray0"), Some(Color::Indexed(232))); assert_eq!(parse_color("gray23"), Some(Color::Indexed(255))); assert_eq!( parse_color("gray99"), Some(Color::Indexed(255)), "clamped to the end of the ramp rather than overflowing" ); } #[test] fn mjb_llr_183_bright_colour_is_base_plus_eight() { // The template's `wrapping_shl(8)` masked to a zero-bit shift, so // bright colours were indistinguishable from their base. assert_eq!(parse_color("bright color1"), Some(Color::Indexed(9))); assert_ne!(parse_color("bright color1"), parse_color("color1")); assert_eq!( parse_color("bright color255"), Some(Color::Indexed(255)), "saturates instead of wrapping" ); } /// MJB-LLR-180: the compiled-in default TOML parses, and carries the /// bindings the requirements mandate. #[test] fn mjb_llr_180_builtin_defaults_parse() { let c: Config = toml::from_str(CONFIG).expect("built-in config.toml must parse"); let normal = c.keybindings.0.get(&Mode::Normal).expect("normal bindings"); assert_eq!( normal.get(&parse_key_sequence("").unwrap()), Some(&Command::MoveCharLeft) ); // A two-key sequence must survive parsing as two events. let gg = parse_key_sequence("").unwrap(); assert_eq!(gg.len(), 2); assert_eq!(normal.get(&gg), Some(&Command::GotoFileStart)); let global = c.keybindings.0.get(&Mode::Global).expect("global bindings"); assert_eq!( global.get(&parse_key_sequence("").unwrap()), Some(&Command::Quit) ); } /// MJB-LLR-181: `config.toml` is the only file consulted. A config in any /// other format sitting in the same directory must be ignored entirely. #[test] fn mjb_llr_181_only_config_toml_is_read() { let dir = tempfile::tempdir().unwrap(); // Decoys in the formats the template used to accept. std::fs::write( dir.path().join("config.json5"), "{ \"keybindings\": { \"normal\": { \"\": \"Quit\" } } }", ) .unwrap(); std::fs::write(dir.path().join("config.json"), "{\"editor\":{\"scrolloff\":99}}").unwrap(); std::fs::write(dir.path().join("config.yaml"), "editor:\n scrolloff: 98\n").unwrap(); std::fs::write(dir.path().join("config.ini"), "[editor]\nscrolloff=97\n").unwrap(); std::fs::write(dir.path().join("config.toml"), "[editor]\nscrolloff = 7\n").unwrap(); let cfg: Config = config::Config::builder() .add_source( config::File::from(dir.path().join("config.toml")) .format(config::FileFormat::Toml) .required(false), ) .build() .unwrap() .try_deserialize() .unwrap(); assert_eq!(cfg.editor.scrolloff, 7, "the TOML file must win"); } /// MJB-LLR-185: editor settings deserialize with the documented defaults. #[test] fn mjb_llr_185_editor_defaults() { let c: Config = toml::from_str(CONFIG).unwrap(); assert_eq!(c.editor.scrolloff, 5); assert!(c.editor.insert_final_newline); let empty: Config = toml::from_str("").unwrap(); assert_eq!(empty.editor.scrolloff, 5); assert!(empty.editor.insert_final_newline); } /// MJB-LLR-182: a user binding overrides one default without disturbing /// the rest of that mode. #[test] fn mjb_llr_182_user_bindings_merge_per_binding() { let mut defaults: Config = toml::from_str(CONFIG).unwrap(); let user: Config = toml::from_str("[keybindings.normal]\n\"\" = \"MoveCharRight\"\n").unwrap(); // Same merge direction as Config::new: user wins, defaults fill in. let mut merged = user; for (mode, default_bindings) in defaults.keybindings.0.drain() { let entry = merged.keybindings.0.entry(mode).or_default(); for (key, cmd) in default_bindings { entry.entry(key).or_insert(cmd); } } let normal = merged.keybindings.0.get(&Mode::Normal).unwrap(); assert_eq!( normal.get(&parse_key_sequence("").unwrap()), Some(&Command::MoveCharRight), "user binding must win" ); assert_eq!( normal.get(&parse_key_sequence("").unwrap()), Some(&Command::MoveLineDown), "untouched defaults must remain" ); } /// MJB-LLR-183: a malformed key sequence is a recoverable error naming the /// offending string, not a panic. #[test] fn mjb_llr_183_invalid_keybinding_is_recoverable() { let err = toml::from_str::("[keybindings.normal]\n\"\" = \"Undo\"\n") .expect_err("must not deserialize"); assert!( err.to_string().contains("nonsense-key"), "error must name the offending key, got: {err}" ); } /// MJB-LLR-184: modes deserialize from their lower-case names. #[test] fn mjb_llr_184_modes_deserialize_lowercase() { let c: Config = toml::from_str( "[keybindings.normal]\n\"\" = \"Undo\"\n\ [keybindings.insert]\n\"\" = \"Undo\"\n\ [keybindings.select]\n\"\" = \"Undo\"\n\ [keybindings.command]\n\"\" = \"Undo\"\n\ [keybindings.global]\n\"\" = \"Undo\"\n", ) .unwrap(); for mode in [ Mode::Normal, Mode::Insert, Mode::Select, Mode::Command, Mode::Global, ] { assert!(c.keybindings.0.contains_key(&mode), "missing {mode:?}"); } } #[test] fn test_simple_keys() { assert_eq!( parse_key_event("a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()) ); assert_eq!( parse_key_event("enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()) ); assert_eq!( parse_key_event("esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()) ); } #[test] fn test_with_modifiers() { assert_eq!( parse_key_event("ctrl-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) ); assert_eq!( parse_key_event("alt-enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) ); assert_eq!( parse_key_event("shift-esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT) ); } #[test] fn test_multiple_modifiers() { assert_eq!( parse_key_event("ctrl-alt-a").unwrap(), KeyEvent::new( KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT ) ); assert_eq!( parse_key_event("ctrl-shift-enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) ); } #[test] fn test_reverse_multiple_modifiers() { assert_eq!( key_event_to_string(&KeyEvent::new( KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT )), "ctrl-alt-a".to_string() ); } #[test] fn test_invalid_keys() { assert!(parse_key_event("invalid-key").is_err()); assert!(parse_key_event("ctrl-invalid-key").is_err()); } #[test] fn test_case_insensitivity() { assert_eq!( parse_key_event("CTRL-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) ); assert_eq!( parse_key_event("AlT-eNtEr").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) ); } }