Files
dungeoncraft/src/i18n.rs
T

163 lines
5.5 KiB
Rust

// Internationalisation: in-game texts live in lang/*.lang files (key = text).
// English is the reference language and the fallback for missing keys.
// To add a language, create lang/<code>.lang and add it to SOURCES.
use std::cell::RefCell;
use std::collections::HashMap;
// English first: it is the fallback for missing keys
const SOURCES: &[(&str, &str)] = &[
("en", include_str!("../lang/en.lang")),
("de", include_str!("../lang/de.lang")),
("es", include_str!("../lang/es.lang")),
("ru", include_str!("../lang/ru.lang")),
("it", include_str!("../lang/it.lang")),
("pt", include_str!("../lang/pt.lang")),
("nl", include_str!("../lang/nl.lang")),
("da", include_str!("../lang/da.lang")),
("no", include_str!("../lang/no.lang")),
("sv", include_str!("../lang/sv.lang")),
("fi", include_str!("../lang/fi.lang")),
("hr", include_str!("../lang/hr.lang")),
("hu", include_str!("../lang/hu.lang")),
("sr", include_str!("../lang/sr.lang")),
("ro", include_str!("../lang/ro.lang")),
("sk", include_str!("../lang/sk.lang")),
("cs", include_str!("../lang/cs.lang")),
("el", include_str!("../lang/el.lang")),
];
// order of the languages in the language dropdown
const ORDER: &[&str] = &["de", "en", "es", "pt", "nl", "da", "no", "sv", "fi", "ru", "hr", "hu", "sr", "ro", "sk", "cs", "it", "el"];
struct I18n {
langs: Vec<(String, HashMap<String, String>)>,
current: usize,
}
fn parse(src: &str) -> HashMap<String, String> {
let mut m = HashMap::new();
for line in src.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
m.insert(k.trim().to_string(), v.trim().to_string());
}
}
m
}
thread_local! {
static I18N: RefCell<I18n> = RefCell::new(I18n {
langs: SOURCES.iter().map(|(code, src)| (code.to_string(), parse(src))).collect(),
current: 0,
});
}
// Available languages: (code, display name in that language)
pub fn languages() -> Vec<(String, String)> {
let mut l: Vec<(String, String)> = I18N.with(|i| {
i.borrow().langs.iter().map(|(c, m)| (c.clone(), m.get("lang.name").cloned().unwrap_or_else(|| c.clone()))).collect()
});
l.sort_by_key(|(c, _)| ORDER.iter().position(|o| o == c).unwrap_or(ORDER.len()));
l
}
pub fn set_language(code: &str) -> bool {
I18N.with(|i| {
let mut i = i.borrow_mut();
if let Some(k) = i.langs.iter().position(|(c, _)| c == code) {
i.current = k;
true
} else {
false
}
})
}
#[allow(dead_code)] // used by the settings menu
pub fn language() -> String {
I18N.with(|i| {
let i = i.borrow();
i.langs[i.current].0.clone()
})
}
// Translate a key (fallback: English, then the key itself)
pub fn tr(key: &str) -> String {
I18N.with(|i| {
let i = i.borrow();
i.langs[i.current].1.get(key).or_else(|| i.langs[0].1.get(key)).cloned().unwrap_or_else(|| key.to_string())
})
}
// Translate with arguments. Arguments starting with '@' are keys and get translated too.
pub fn trf(key: &str, args: &[String]) -> String {
let mut s = tr(key);
for (n, a) in args.iter().enumerate() {
let v = if let Some(k) = a.strip_prefix('@') {
tr(k)
} else if let Some(n) = a.strip_prefix('%') {
crate::names::from_arg(n) // a creature's name (names.rs)
} else {
a.clone()
};
s = s.replace(&format!("{{{}}}", n), &v);
}
s
}
// Translate a key in a specific language (used by the language selection dialog)
pub fn tr_in(code: &str, key: &str) -> String {
I18N.with(|i| {
let i = i.borrow();
i.langs.iter().find(|(c, _)| c == code).and_then(|(_, m)| m.get(key)).cloned().unwrap_or_else(|| key.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn placeholders(v: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut rest = v;
while let Some(a) = rest.find('{') {
match rest[a..].find('}') {
Some(b) => {
out.insert(rest[a..a + b + 1].to_string());
rest = &rest[a + b + 1..];
}
None => break,
}
}
out
}
#[test]
fn every_language_has_all_keys_and_placeholders() {
let en = parse(SOURCES[0].1);
for (code, src) in SOURCES.iter().skip(1) {
let m = parse(src);
let missing: Vec<&String> = en.keys().filter(|k| !m.contains_key(*k)).collect();
let extra: Vec<&String> = m.keys().filter(|k| !en.contains_key(*k)).collect();
assert!(missing.is_empty() && extra.is_empty(), "{}: missing {:?}, extra {:?}", code, missing, extra);
for (k, v) in &en {
assert_eq!(placeholders(v), placeholders(&m[k]), "{}: placeholders of {}", code, k);
}
assert!(m.get("lang.name").map_or(false, |n| !n.is_empty()), "{} has a name", code);
assert!(ORDER.contains(code), "{} has a place in the dropdown", code);
}
}
#[test]
fn the_dropdown_follows_the_chosen_order() {
let codes: Vec<String> = languages().into_iter().map(|(c, _)| c).collect();
let pos = |c: &str| ORDER.iter().position(|o| *o == c).unwrap();
assert!(codes.windows(2).all(|w| pos(&w[0]) < pos(&w[1])), "{:?}", codes);
}
}