diff --git a/CHANGELOG.md b/CHANGELOG.md index dc135a7..d05abd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features +- Support localization (#33) - Search by keywords in apps mode (#20) - Magic separators support: `!!` for args, `#` for envs and `~` for workdir (#19) - Display full path for ambiguous binapps (0b47575) diff --git a/Cargo.lock b/Cargo.lock index 932fe0b..3e2de65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1574,6 +1574,7 @@ dependencies = [ "freedesktop_entry_parser", "fzyr", "itertools", + "libc", "log", "nix 0.19.1", "nom 6.0.1", diff --git a/Cargo.toml b/Cargo.toml index 031aaf3..d804eb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ itertools = "0.9.0" euclid = "0.22.1" nom = { version = "6.0.1", default-features = false, features = ["std", "regexp"] } regex = "1.4.2" +libc = "0.2.81" [profile.release] lto = true diff --git a/src/desktop.rs b/src/desktop.rs index ec1341c..4955c25 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -6,6 +6,8 @@ use xdg::BaseDirectories; use crate::icon::Icon; +mod locale; + pub static XDG_DIRS: OnceCell = OnceCell::new(); pub struct Entry { @@ -83,8 +85,19 @@ fn traverse_dir_entry(mut entries: &mut Vec, dir_entry: DirEntry) { return; } }; + let main_section = entry.section("Desktop Entry"); - match (main_section.attr("Name"), main_section.attr("Exec")) { + let locale = locale::Locale::current(); + + let localized_entry = |attr_name: &str| { + locale + .keys() + .filter_map(|key| main_section.attr_with_param(attr_name, key)) + .next() + .or_else(|| main_section.attr(attr_name)) + }; + + match (localized_entry("Name"), main_section.attr("Exec")) { (Some(n), Some(e)) => { entries.push(Entry { name: n.to_owned(), @@ -96,14 +109,12 @@ fn traverse_dir_entry(mut entries: &mut Vec, dir_entry: DirEntry) { .to_owned(), path: dir_entry_path, exec: e.to_owned(), - // TODO: use `attr_with_param` with locale first - name_with_keywords: n.to_owned() - + main_section.attr("Keywords").unwrap_or_default(), + name_with_keywords: n.to_owned() + localized_entry("Keywords").unwrap_or_default(), is_terminal: main_section .attr("Terminal") .map(|s| s == "true") .unwrap_or(false), - icon: main_section.attr("Icon").and_then(|name| { + icon: localized_entry("Icon").and_then(|name| { let icon_path = Path::new(name); if icon_path.is_absolute() { diff --git a/src/desktop/locale.rs b/src/desktop/locale.rs new file mode 100644 index 0000000..713c2a2 --- /dev/null +++ b/src/desktop/locale.rs @@ -0,0 +1,135 @@ +use std::ffi::CStr; + +use once_cell::sync::OnceCell; +use regex::Regex; + +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +pub struct Locale<'a> { + lang: Option<&'a str>, + country: Option<&'a str>, + modifier: Option<&'a str>, +} + +const LOCALE_REGEX: &str = r#"(?x) + ^ + ([[:alpha:]]+) # lang + (?:_([[:alpha:]]+))? # country + (?:\.[^@]*)? # encoding + (?:@(.*))? # modifier + $"#; + +impl<'a> Locale<'a> { + fn from_caputres(s: &'a str, captures: regex::Captures<'_>) -> Self { + Self { + lang: captures.get(1).map(|m| &s[m.range()]), + country: captures.get(2).map(|m| &s[m.range()]), + modifier: captures.get(3).map(|m| &s[m.range()]), + } + } +} + +impl Locale<'static> { + pub fn current<'a>() -> &'a Self { + static LOCALE: OnceCell>> = OnceCell::new(); + LOCALE + .get_or_init(|| { + let s = unsafe { + let ptr = libc::setlocale(libc::LC_MESSAGES, b"\0".as_ptr().cast()); + if ptr.is_null() { + return None; + } + CStr::from_ptr(ptr) + } + .to_str() + .ok()?; + + let re = Regex::new(LOCALE_REGEX).unwrap(); + + let c = re.captures(s)?; + + Some(Self::from_caputres(s, c)) + }) + .as_ref() + .unwrap_or(&Self { + lang: None, + country: None, + modifier: None, + }) + } + + pub fn keys(&self) -> impl Iterator> + '_ { + static LOCALE_ITERS: OnceCell> = OnceCell::new(); + LOCALE_ITERS + .get_or_init(|| { + let mut v = vec![]; + if let Some(((l, c), m)) = self.lang.zip(self.country).zip(self.modifier) { + v.push(format!("{}_{}@{}", l, c, m)); + } + if let Some((l, c)) = self.lang.zip(self.country) { + v.push(format!("{}_{}", l, c)); + } + if let Some((l, m)) = self.lang.zip(self.modifier) { + v.push(format!("{}@{}", l, m)); + } + if let Some(l) = self.lang { + v.push(l.to_string()); + } + + v + }) + .clone() + .into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use test_case::test_case; + + #[test] + fn regex_compiles() { + let _ = Regex::new(LOCALE_REGEX).unwrap(); + } + + #[test] + fn regex_doesnt_match_empty() { + let re = Regex::new(LOCALE_REGEX).unwrap(); + assert!(re.captures("").is_none()); + } + + impl Locale<'static> { + fn new( + lang: impl Into>, + country: impl Into>, + modifier: impl Into>, + ) -> Self { + Self { + lang: lang.into(), + country: country.into(), + modifier: modifier.into(), + } + } + } + + #[test_case("qw", Locale::new("qw", None, None); "lang")] + #[test_case("qw_ER", Locale::new("qw", "ER", None); "lang, country")] + #[test_case("qw_ER.ty", Locale::new("qw", "ER", None); "lang, country, encoding")] + #[test_case( + "qw_ER.ty@ui", + Locale::new("qw", "ER", "ui"); + "lang, country, encoding, modifier" + )] + #[test_case("qw@ui", Locale::new("qw", None, "ui"); "lang, modifier")] + fn regex_compiles(s: &str, x: Locale<'static>) { + let re = Regex::new(LOCALE_REGEX).unwrap(); + let c = re.captures(s).unwrap(); + + let m = c.get(0).unwrap(); + assert_eq!(m.start(), 0); + assert_eq!(m.end(), s.len()); + + assert_eq!(Locale::from_caputres(s, c), x); + } +}