mirror of
https://github.com/alexpasmantier/television.git
synced 2025-06-06 19:45:23 +00:00

* fix: remove org name from default platform config and data dirs * fix: adjust config and data dirs fallback logic * patch * update README
668 lines
20 KiB
Rust
668 lines
20 KiB
Rust
#![allow(clippy::module_name_repetitions)]
|
|
use std::{collections::HashMap, env, path::PathBuf};
|
|
|
|
use crate::{
|
|
action::Action,
|
|
event::{convert_raw_event_to_key, Key},
|
|
television::Mode,
|
|
};
|
|
use color_eyre::Result;
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use derive_deref::{Deref, DerefMut};
|
|
use directories::ProjectDirs;
|
|
use lazy_static::lazy_static;
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use serde::{de::Deserializer, Deserialize};
|
|
use television_previewers::previewers::{self, PreviewerConfig};
|
|
use tracing::{debug, warn};
|
|
|
|
const CONFIG: &str = include_str!("../../.config/config.toml");
|
|
|
|
#[allow(dead_code, clippy::module_name_repetitions)]
|
|
#[derive(Clone, Debug, Deserialize, Default)]
|
|
pub struct AppConfig {
|
|
#[serde(default)]
|
|
pub data_dir: PathBuf,
|
|
#[serde(default)]
|
|
pub config_dir: PathBuf,
|
|
}
|
|
|
|
const DEFAULT_UI_SCALE: u16 = 90;
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct UiConfig {
|
|
pub use_nerd_font_icons: bool,
|
|
pub ui_scale: u16,
|
|
}
|
|
|
|
impl Default for UiConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
use_nerd_font_icons: false,
|
|
ui_scale: DEFAULT_UI_SCALE,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Default)]
|
|
pub struct PreviewersConfig {
|
|
#[serde(default)]
|
|
pub basic: BasicPreviewerConfig,
|
|
#[serde(default)]
|
|
pub directory: DirectoryPreviewerConfig,
|
|
pub file: FilePreviewerConfig,
|
|
#[serde(default)]
|
|
pub env_var: EnvVarPreviewerConfig,
|
|
}
|
|
|
|
impl From<PreviewersConfig> for PreviewerConfig {
|
|
fn from(val: PreviewersConfig) -> Self {
|
|
PreviewerConfig::default()
|
|
.file(previewers::FilePreviewerConfig::new(val.file.theme.clone()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Default)]
|
|
pub struct BasicPreviewerConfig {}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Default)]
|
|
pub struct DirectoryPreviewerConfig {}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Default)]
|
|
pub struct FilePreviewerConfig {
|
|
//pub max_file_size: u64,
|
|
pub theme: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Default)]
|
|
pub struct EnvVarPreviewerConfig {}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Clone, Debug, Default, Deserialize)]
|
|
pub struct Config {
|
|
#[allow(clippy::struct_field_names)]
|
|
#[serde(default, flatten)]
|
|
pub config: AppConfig,
|
|
#[serde(default)]
|
|
pub keybindings: KeyBindings,
|
|
#[serde(default)]
|
|
pub styles: Styles,
|
|
pub ui: UiConfig,
|
|
#[serde(default)]
|
|
pub previewers: PreviewersConfig,
|
|
}
|
|
|
|
lazy_static! {
|
|
pub static ref PROJECT_NAME: String = String::from("television");
|
|
pub static ref PROJECT_NAME_UPPER: String = PROJECT_NAME.to_uppercase().to_string();
|
|
pub static ref DATA_FOLDER: Option<PathBuf> =
|
|
// if `TELEVISION_DATA` is set, use that as the data directory
|
|
env::var_os(format!("{}_DATA", PROJECT_NAME_UPPER.clone())).map(PathBuf::from).or_else(|| {
|
|
// otherwise, use the XDG data directory
|
|
env::var_os("XDG_DATA_HOME").map(PathBuf::from).map(|p| p.join(PROJECT_NAME.as_str())).filter(|p| p.is_absolute())
|
|
});
|
|
pub static ref CONFIG_FOLDER: Option<PathBuf> =
|
|
// if `TELEVISION_CONFIG` is set, use that as the television config directory
|
|
env::var_os(format!("{}_CONFIG", PROJECT_NAME_UPPER.clone())).map(PathBuf::from).or_else(|| {
|
|
// otherwise, use the XDG config directory + 'television'
|
|
env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).map(|p| p.join(PROJECT_NAME.as_str())).filter(|p| p.is_absolute())
|
|
});
|
|
}
|
|
|
|
const CONFIG_FILE_NAME: &str = "config.toml";
|
|
|
|
impl Config {
|
|
#[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)]
|
|
pub fn new() -> Result<Self, config::ConfigError> {
|
|
let default_config: Config =
|
|
toml::from_str(CONFIG).expect("default config should be valid");
|
|
|
|
let data_dir = get_data_dir();
|
|
let config_dir = get_config_dir();
|
|
let mut builder = config::Config::builder()
|
|
.set_default("data_dir", data_dir.to_str().unwrap())?
|
|
.set_default("config_dir", config_dir.to_str().unwrap())?;
|
|
|
|
// Load the default_config values as base defaults
|
|
builder = builder.add_source(config::File::from_str(
|
|
CONFIG,
|
|
config::FileFormat::Toml,
|
|
));
|
|
|
|
let source = config::File::from(config_dir.join(CONFIG_FILE_NAME))
|
|
.format(config::FileFormat::Toml)
|
|
.required(false);
|
|
builder = builder.add_source(source);
|
|
|
|
if !config_dir.join(CONFIG_FILE_NAME).is_file() {
|
|
warn!("No config file found at {:?}", config_dir);
|
|
}
|
|
|
|
let mut cfg: Self = builder.build()?.try_deserialize()?;
|
|
|
|
for (mode, default_bindings) in default_config.keybindings.iter() {
|
|
let user_bindings = cfg.keybindings.entry(*mode).or_default();
|
|
for (key, cmd) in default_bindings {
|
|
user_bindings.entry(*key).or_insert_with(|| cmd.clone());
|
|
}
|
|
}
|
|
for (mode, default_styles) in default_config.styles.iter() {
|
|
let user_styles = cfg.styles.entry(*mode).or_default();
|
|
for (style_key, style) in default_styles {
|
|
user_styles.entry(style_key.clone()).or_insert(*style);
|
|
}
|
|
}
|
|
|
|
Ok(cfg)
|
|
}
|
|
}
|
|
|
|
pub fn get_data_dir() -> PathBuf {
|
|
let directory = if let Some(s) = DATA_FOLDER.clone() {
|
|
debug!("Using data directory: {:?}", s);
|
|
s
|
|
} else if let Some(proj_dirs) = project_directory() {
|
|
debug!("Falling back to default data dir");
|
|
proj_dirs.data_local_dir().to_path_buf()
|
|
} else {
|
|
PathBuf::from(".").join(".data")
|
|
};
|
|
directory
|
|
}
|
|
|
|
pub fn get_config_dir() -> PathBuf {
|
|
let directory = if let Some(s) = CONFIG_FOLDER.clone() {
|
|
debug!("Using config directory: {:?}", s);
|
|
s
|
|
} else if let Some(proj_dirs) = project_directory() {
|
|
debug!("Falling back to default config dir");
|
|
proj_dirs.config_local_dir().to_path_buf()
|
|
} else {
|
|
PathBuf::from(".").join(".config")
|
|
};
|
|
directory
|
|
}
|
|
|
|
fn project_directory() -> Option<ProjectDirs> {
|
|
ProjectDirs::from("com", "", env!("CARGO_PKG_NAME"))
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Deref, DerefMut)]
|
|
pub struct KeyBindings(pub HashMap<Mode, HashMap<Key, Action>>);
|
|
|
|
impl<'de> Deserialize<'de> for KeyBindings {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let parsed_map =
|
|
HashMap::<Mode, HashMap<String, Action>>::deserialize(
|
|
deserializer,
|
|
)?;
|
|
|
|
let keybindings = parsed_map
|
|
.into_iter()
|
|
.map(|(mode, inner_map)| {
|
|
let converted_inner_map = inner_map
|
|
.into_iter()
|
|
.map(|(key_str, cmd)| (parse_key(&key_str).unwrap(), cmd))
|
|
.collect();
|
|
(mode, converted_inner_map)
|
|
})
|
|
.collect();
|
|
|
|
Ok(KeyBindings(keybindings))
|
|
}
|
|
}
|
|
|
|
fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
|
|
let raw_lower = raw.to_ascii_lowercase();
|
|
let (remaining, modifiers) = extract_modifiers(&raw_lower);
|
|
parse_key_code_with_modifiers(remaining, modifiers)
|
|
}
|
|
|
|
fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
|
|
let mut modifiers = KeyModifiers::empty();
|
|
let mut current = raw;
|
|
|
|
loop {
|
|
match current {
|
|
rest if rest.starts_with("ctrl-") => {
|
|
modifiers.insert(KeyModifiers::CONTROL);
|
|
current = &rest[5..];
|
|
}
|
|
rest if rest.starts_with("alt-") => {
|
|
modifiers.insert(KeyModifiers::ALT);
|
|
current = &rest[4..];
|
|
}
|
|
rest if rest.starts_with("shift-") => {
|
|
modifiers.insert(KeyModifiers::SHIFT);
|
|
current = &rest[6..];
|
|
}
|
|
_ => break, // break out of the loop if no known prefix is detected
|
|
};
|
|
}
|
|
|
|
(current, modifiers)
|
|
}
|
|
|
|
fn parse_key_code_with_modifiers(
|
|
raw: &str,
|
|
mut modifiers: KeyModifiers,
|
|
) -> Result<KeyEvent, String> {
|
|
let c = match raw {
|
|
"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" | "minus" => KeyCode::Char('-'),
|
|
"tab" => KeyCode::Tab,
|
|
c if c.len() == 1 => {
|
|
let mut c = c.chars().next().unwrap();
|
|
if modifiers.contains(KeyModifiers::SHIFT) {
|
|
c = c.to_ascii_uppercase();
|
|
}
|
|
KeyCode::Char(c)
|
|
}
|
|
_ => return Err(format!("Unable to parse {raw}")),
|
|
};
|
|
Ok(KeyEvent::new(c, modifiers))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
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(raw: &str) -> Result<Key, 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("><") {
|
|
raw
|
|
} else {
|
|
let raw = raw.strip_prefix('<').unwrap_or(raw);
|
|
let raw = raw.strip_suffix('>').unwrap_or(raw);
|
|
raw
|
|
};
|
|
let key_event = parse_key_event(raw)?;
|
|
Ok(convert_raw_event_to_key(key_event))
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Deref, DerefMut)]
|
|
pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
|
|
|
|
impl<'de> Deserialize<'de> for Styles {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let parsed_map =
|
|
HashMap::<Mode, HashMap<String, String>>::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)
|
|
}
|
|
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
fn parse_color(s: &str) -> Option<Color> {
|
|
let s = s.trim_start();
|
|
let s = s.trim_end();
|
|
if s.contains("bright color") {
|
|
let s = s.trim_start_matches("bright ");
|
|
let c = s
|
|
.trim_start_matches("color")
|
|
.parse::<u8>()
|
|
.unwrap_or_default();
|
|
Some(Color::Indexed(c.wrapping_shl(8)))
|
|
} else if s.contains("color") {
|
|
let c = s
|
|
.trim_start_matches("color")
|
|
.parse::<u8>()
|
|
.unwrap_or_default();
|
|
Some(Color::Indexed(c))
|
|
} else if s.contains("gray") {
|
|
let c = 232
|
|
+ s.trim_start_matches("gray")
|
|
.parse::<u8>()
|
|
.unwrap_or_default();
|
|
Some(Color::Indexed(c))
|
|
} else if s.contains("rgb") {
|
|
let red =
|
|
(s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
|
|
let green =
|
|
(s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
|
|
let blue =
|
|
(s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
|
|
let c = 16 + red * 36 + green * 6 + blue;
|
|
Some(Color::Indexed(c))
|
|
} 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);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config() -> Result<()> {
|
|
let c = Config::new()?;
|
|
assert_eq!(
|
|
c.keybindings
|
|
.get(&Mode::Channel)
|
|
.unwrap()
|
|
.get(&parse_key("esc").unwrap())
|
|
.unwrap(),
|
|
&Action::Quit
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[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)
|
|
);
|
|
}
|
|
}
|