Compare commits

...

2 Commits

8 changed files with 390 additions and 52 deletions

97
Cargo.lock generated
View File

@ -257,6 +257,15 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
[[package]]
name = "clipboard-win"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9a0700e0127ba15d1d52dd742097f821cd9c65939303a44d970465040a297"
dependencies = [
"error-code",
]
[[package]]
name = "cnx"
version = "0.3.0"
@ -309,6 +318,12 @@ version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]]
name = "env_logger"
version = "0.10.2"
@ -338,6 +353,23 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "error-code"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b"
[[package]]
name = "fd-lock"
version = "4.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e5768da2206272c81ef0b5e951a41862938a6070da63bcea197899942d3b947"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.52.0",
]
[[package]]
name = "futures"
version = "0.3.30"
@ -553,6 +585,7 @@ dependencies = [
"paste",
"pretty_assertions",
"pretty_env_logger",
"rustyline",
"serde",
"serde_json",
"strum",
@ -685,6 +718,26 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
dependencies = [
"bitflags 2.4.2",
"cfg-if",
"libc",
]
[[package]]
name = "num-traits"
version = "0.2.18"
@ -877,6 +930,16 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "radix_trie"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
dependencies = [
"endian-type",
"nibble_vec",
]
[[package]]
name = "regex"
version = "1.10.3"
@ -931,6 +994,28 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
[[package]]
name = "rustyline"
version = "13.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02a2d683a4ac90aeef5b1013933f6d977bd37d51ff3f4dad829d4931a7e6be86"
dependencies = [
"bitflags 2.4.2",
"cfg-if",
"clipboard-win",
"fd-lock",
"home",
"libc",
"log",
"memchr",
"nix",
"radix_trie",
"unicode-segmentation",
"unicode-width",
"utf8parse",
"winapi",
]
[[package]]
name = "ryu"
version = "1.0.17"
@ -1189,6 +1274,18 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-segmentation"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
[[package]]
name = "unicode-width"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
[[package]]
name = "utf8parse"
version = "0.2.1"

View File

@ -18,6 +18,7 @@ which = "6.0.0"
log = "0.4"
pretty_env_logger = "0.5"
cnx = { git = "https://github.com/mjkillough/cnx.git", rev = "7845d99baa296901171c083db61588a62f9a8b34" }
rustyline = "13.0.0"
[dev-dependencies]
pretty_assertions = "1.4.0"

81
src/add/mod.rs Normal file
View File

@ -0,0 +1,81 @@
use clap::Subcommand;
use rustyline::{error::ReadlineError, DefaultEditor};
use thiserror::Error;
use crate::hlwm::{
command::{CommandParseError, HlwmCommand},
key::Key,
parser::{FromCommandArgs, ParseError},
};
#[derive(Subcommand, Debug, Clone)]
pub enum Add {
/// Add a new keybind to the existing hlctl config
#[clap(arg_required_else_help = true)]
Keybind {
#[arg(short, long)]
interactive: bool,
},
}
#[derive(Debug, Clone, Error)]
pub enum Error {
#[error("readline error: [{0}]")]
ReadlineError(String),
#[error("parse error: [{0}]")]
KeyParseError(#[from] ParseError),
#[error("could not parse command: [{0}]")]
CommandParseError(#[from] CommandParseError),
}
impl From<ReadlineError> for Error {
fn from(value: ReadlineError) -> Self {
Self::ReadlineError(value.to_string())
}
}
impl Add {
pub fn run(self) -> Result<(), Error> {
match self {
Add::Keybind { interactive } => {
if interactive {
keybind_interactive()
} else {
todo!()
}
}
}
}
}
fn keybind_interactive() -> Result<(), Error> {
let mut read = DefaultEditor::new()?;
let keys = read
.readline("Keys (Separated by spaces, tabs, +, or -): ")?
.trim()
.split([' ', '\t', '+', '-'])
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|k| Key::from_str_case_insensitive(k))
.collect::<Result<Vec<_>, _>>()?;
let command = {
let read = read.readline("Command (Arguments separated by tabs or spaces): ")?;
let mut args = read
.trim()
.split([' ', '\t'])
.map(|a| a.trim())
.filter(|a| !a.is_empty())
.map(|a| a.to_string());
match args.next() {
Some(cmd) => HlwmCommand::from_command_args(&cmd, args)?,
None => {
println!("No command provided");
std::process::exit(1);
}
}
};
println!("{keys:?} -> {command:?}");
Ok(())
}

View File

@ -962,6 +962,9 @@ mod test {
value: Attribute::Color(Color::X11(X11Color::NavajoWhite3)),
});
let cfg_serialized = cfg.serialize().unwrap();
println!("--> Config <--");
println!("{cfg_serialized}");
println!("--> Config <--");
let parsed_cfg = Config::deserialize(&cfg_serialized).unwrap();
assert_eq!(cfg, parsed_cfg,)

View File

@ -277,7 +277,7 @@ impl Default for HlwmCommand {
}
}
#[derive(Debug, Error)]
#[derive(Debug, Clone, Error)]
pub enum CommandParseError {
#[error("invalid argument count [{0}] at [{1}]")]
InvalidArgumentCount(usize, String),
@ -286,11 +286,17 @@ pub enum CommandParseError {
#[error("command execution error: [{0}]")]
CommandError(#[from] CommandError),
#[error("string utf8 error")]
StringUtf8Error(#[from] FromUtf8Error),
StringUtf8Error(String),
#[error("parsing error: [{0}]")]
StringParseError(#[from] ParseError),
}
impl From<FromUtf8Error> for CommandParseError {
fn from(value: FromUtf8Error) -> Self {
Self::StringUtf8Error(value.to_string())
}
}
impl serde::de::Expected for CommandParseError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "expected valid command string. Got error: {self}")

View File

@ -3,7 +3,7 @@ use std::{borrow::BorrowMut, fmt::Display, str::FromStr};
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use crate::split;
use crate::{logerr::UnwrapLog, split};
use super::{
command::HlwmCommand,
@ -43,6 +43,21 @@ pub enum Key {
Mouse(MouseButton),
}
fn title_case(s: &str) -> String {
let mut chars = s.chars();
let mut out_chars = Vec::with_capacity(s.len());
if let Some(first) = chars.next() {
out_chars.push(first.to_ascii_uppercase());
}
while let Some(c) = chars.next() {
out_chars.push(c.to_ascii_lowercase());
}
out_chars.into_iter().collect()
}
impl Key {
pub fn is_standard(&self) -> bool {
match self {
@ -68,6 +83,57 @@ impl Key {
.map(Key::from_str)
.collect::<Result<Vec<_>, _>>()
}
pub fn from_str_case_insensitive(s: &str) -> Result<Self, ParseError> {
Key::from_str(&title_case(s))
}
/// Returns the name for this key as it should appear in the config
pub fn config_name(&self) -> String {
self.aliases()
.first()
.expect_log("all keys should have at least one alias")
.to_string()
}
/// Returns a list of aliases for the key
pub fn aliases(&self) -> Vec<String> {
match self {
Key::Mod1Alt => vec!["Alt".into(), "Mod1".into()],
Key::Mod4Super => vec!["Super".into(), "Mod4".into()],
Key::Return => vec!["Return".into(), "Enter".into()],
Key::Shift => vec!["Shift".into()],
Key::Tab => vec!["Tab".into()],
Key::Left => vec!["Left".into()],
Key::Right => vec!["Right".into()],
Key::Up => vec!["Up".into()],
Key::Down => vec!["Down".into()],
Key::Space => vec!["Space".into()],
Key::Control => vec!["Ctrl".into(), "Ctl".into(), "Control".into()],
Key::Backtick => vec!["Backtick".into(), "Grave".into()],
Key::F1 => vec!["F1".into()],
Key::F2 => vec!["F2".into()],
Key::F3 => vec!["F3".into()],
Key::F4 => vec!["F4".into()],
Key::F5 => vec!["F5".into()],
Key::F6 => vec!["F6".into()],
Key::F7 => vec!["F7".into()],
Key::F8 => vec!["F8".into()],
Key::F9 => vec!["F9".into()],
Key::F10 => vec!["F10".into()],
Key::F11 => vec!["F11".into()],
Key::F12 => vec!["F12".into()],
Key::Home => vec!["Home".into()],
Key::Delete => vec!["Delete".into(), "Del".into()],
Key::Char(c) => vec![c.to_ascii_lowercase().to_string()],
Key::Mouse(m) => vec![m.to_string()],
}
}
}
impl Display for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.aliases().first().unwrap())
}
}
impl From<MouseButton> for Key {
@ -87,7 +153,7 @@ impl Serialize for Key {
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
serializer.serialize_str(&self.config_name())
}
}
@ -103,7 +169,7 @@ impl<'de> Deserialize<'de> for Key {
}
}
let str_val: String = Deserialize::deserialize(deserializer)?;
Ok(Self::from_str(&str_val).map_err(|_| {
Ok(Self::from_str_case_insensitive(&str_val).map_err(|_| {
serde::de::Error::invalid_value(serde::de::Unexpected::Str(&str_val), &ExpectedKey)
})?)
}
@ -118,14 +184,16 @@ impl FromStr for Key {
Ok(Self::Mouse(MouseButton::from_str(s)?))
}
_ => {
if let Some(key) = Self::iter().into_iter().find(|key| key.to_string() == s) {
match key {
Key::Char(_) | Key::Mouse(_) => (),
_ => return Ok(key),
}
if let Some(key) = Self::iter().into_iter().find(|key| match key {
Key::Char(_) | Key::Mouse(_) => false,
_ => key.aliases().contains(&s.to_string()),
}) {
return Ok(key);
}
if s.len() == 1 {
Ok(Self::Char(s.chars().next().unwrap()))
Ok(Self::Char(
s.chars().next().unwrap_log().to_ascii_lowercase(),
))
} else {
Err(ParseError::InvalidValue {
value: s.to_string(),
@ -179,42 +247,6 @@ impl Display for MouseButton {
}
}
impl Display for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let data = match self {
Key::Return => "Return".to_string(),
Key::Shift => "Shift".to_string(),
Key::Tab => "Tab".to_string(),
Key::Left => "Left".to_string(),
Key::Right => "Right".to_string(),
Key::Up => "Up".to_string(),
Key::Down => "Down".to_string(),
Key::Space => "space".to_string(),
Key::Control => "Control".to_string(),
Key::Backtick => "grave".to_string(),
Key::Char(c) => c.to_string(),
Key::Mouse(m) => m.to_string(),
Key::F1 => "F1".to_string(),
Key::F2 => "F2".to_string(),
Key::F3 => "F3".to_string(),
Key::F4 => "F4".to_string(),
Key::F5 => "F5".to_string(),
Key::F6 => "F6".to_string(),
Key::F7 => "F7".to_string(),
Key::F8 => "F8".to_string(),
Key::F9 => "F9".to_string(),
Key::F10 => "F10".to_string(),
Key::F11 => "F11".to_string(),
Key::F12 => "F12".to_string(),
Key::Home => "Home".to_string(),
Key::Delete => "Delete".to_string(),
Key::Mod1Alt => "Mod1".to_string(),
Key::Mod4Super => "Mod4".to_string(),
};
f.write_str(&data)
}
}
#[derive(Debug, Clone, strum::Display, strum::EnumIter, PartialEq)]
#[strum(serialize_all = "snake_case")]
pub enum MousebindAction {
@ -456,9 +488,10 @@ mod test {
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use crate::hlwm::command::HlwmCommand;
use crate::{hlwm::command::HlwmCommand, logerr::UnwrapLog};
use super::{Key, MousebindAction};
use pretty_assertions::assert_eq;
#[derive(Debug, Serialize, Deserialize)]
pub struct KeyWrapper {
@ -468,8 +501,8 @@ mod test {
#[test]
fn key_serialize_deserialize() {
for (original_key, wrapper) in Key::iter().map(|key| (key, KeyWrapper { key })) {
let wrapper_str = toml::to_string_pretty(&wrapper).unwrap();
let parsed: KeyWrapper = toml::from_str(&wrapper_str).unwrap();
let wrapper_str = toml::to_string_pretty(&wrapper).expect_log("serialize");
let parsed: KeyWrapper = toml::from_str(&wrapper_str).expect_log("deserialize");
assert_eq!(original_key, parsed.key);
}
}
@ -494,4 +527,60 @@ mod test {
assert_eq!(original_key, parsed.action);
}
}
fn case_random<S: Into<String>>(s: S) -> Vec<String> {
let s = s.into();
let counts: usize = s.chars().map(|c| c.is_ascii_alphabetic() as usize).sum();
if counts < 2 {
return vec![s.to_string()];
}
let mut variants: Vec<String> = Vec::with_capacity(counts + 2);
let mut working = Vec::new();
for idx in 0..counts {
let mut c_idx = 0;
for c in s.chars() {
if !c.is_ascii_alphabetic() {
working.push(c);
continue;
}
if c_idx == idx {
working.push(c.to_ascii_uppercase());
} else {
working.push(c.to_ascii_lowercase());
}
c_idx += 1;
}
variants.push(working.into_iter().collect());
working = Vec::new();
}
variants.push(s.to_ascii_lowercase());
variants.push(s.to_ascii_uppercase());
variants
}
#[test]
fn key_from_case_insensitive() {
let test_cases = Key::iter().map(|key| {
let strings = key
.aliases()
.into_iter()
.map(|a| case_random(a))
.flatten()
.collect::<Vec<_>>();
(strings, key)
});
for (strings, key) in test_cases {
for case in strings {
println!("comparing str: [{case}] with key: [{key}]");
let parsed = Key::from_str_case_insensitive(&case).expect(&format!(
"key [{key}] parse from insensitive string [{case}] failed"
));
assert_eq!(key, parsed);
}
}
}
}

View File

@ -2,10 +2,20 @@ use std::{error::Error, fmt::Debug};
use log::{debug, error};
// So we still get a useful panic when logs are filtered
macro_rules! error_and_panic {
($($arg:tt)*) => {
log::error!($($arg)*);
panic!($($arg)*);
};
}
pub trait UnwrapLog {
type Target;
fn unwrap_or_log(self, default: Self::Target) -> Self::Target;
fn unwrap_log(self) -> Self::Target;
fn expect_log(self, expect: &str) -> Self::Target;
}
impl<T, E> UnwrapLog for Result<T, E>
@ -28,6 +38,26 @@ where
}
}
}
fn unwrap_log(self) -> Self::Target {
match self {
Ok(target) => target,
Err(err) => {
error_and_panic!("called `Result::unwrap_log()` on an `Err` value: {err}");
}
}
}
fn expect_log(self, expect: &str) -> Self::Target {
match self {
Ok(target) => target,
Err(err) => {
error_and_panic!(
"[{expect}] called `Result::expect_log()` on an `Err` value: {err}"
);
}
}
}
}
impl<T> UnwrapLog for Option<T>
@ -49,4 +79,22 @@ where
}
}
}
fn unwrap_log(self) -> Self::Target {
match self {
Some(target) => target,
None => {
error_and_panic!("called `Option::unwrap_log()` on a `None` value");
}
}
}
fn expect_log(self, expect: &str) -> Self::Target {
match self {
Some(target) => target,
None => {
error_and_panic!("[{expect}] called `Option::expect_log()` on an `None` value");
}
}
}
}

View File

@ -1,9 +1,11 @@
use std::path::{Path, PathBuf};
use add::Add;
use clap::{Parser, Subcommand};
use config::Config;
use log::{error, info};
use log::{error, info, LevelFilter};
mod add;
pub mod cmd;
mod config;
pub mod environ;
@ -40,10 +42,16 @@ enum HlctlCommand {
},
/// Start the top panel
Panel,
/// Add an element to the existing hlctl config
#[command(subcommand)]
Add(Add),
}
fn main() {
pretty_env_logger::init();
pretty_env_logger::formatted_builder()
.filter_module("rustyline", LevelFilter::Error)
.parse_default_env()
.init();
let args = Args::parse();
match args.command {
@ -55,6 +63,11 @@ fn main() {
error!("panel: {err}");
}
}
HlctlCommand::Add(add) => {
if let Err(err) = add.run() {
error!("add: {err}");
}
}
}
}