1use std::cell::RefCell;
42use std::collections::{BTreeMap, HashMap, HashSet};
43use std::fmt::Display;
44use std::path::Path;
45
46use glam::Vec3;
47
48use crate::math::{angle_difference, bearing, clock_position};
49use crate::vehicle::Vehicle;
50
51#[derive(Debug, Clone)]
53pub struct Lang {
54 current: String,
55 fallback: String,
56 tables: BTreeMap<String, HashMap<String, String>>,
57 missing: RefCell<HashSet<String>>,
59}
60
61impl Default for Lang {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67impl Lang {
68 pub fn new() -> Self {
70 Self {
71 current: "en".into(),
72 fallback: "en".into(),
73 tables: BTreeMap::new(),
74 missing: RefCell::default(),
75 }
76 }
77
78 pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> usize {
81 let Ok(entries) = std::fs::read_dir(dir.as_ref()) else {
82 return 0;
83 };
84 let mut count = 0;
85 for path in entries.flatten().map(|e| e.path()) {
86 if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("txt")) {
87 continue;
88 }
89 let Some(code) = path.file_stem().and_then(|s| s.to_str()) else {
90 continue;
91 };
92 match std::fs::read_to_string(&path) {
93 Ok(text) => {
94 self.add_text(code, &text);
95 count += 1;
96 }
97 Err(e) => eprintln!("audio-code: could not read {}: {e}", path.display()),
98 }
99 }
100 count
101 }
102
103 pub fn add_text(&mut self, code: &str, text: &str) {
106 let table = self.tables.entry(normalize(code)).or_default();
107 for (number, line) in text.lines().enumerate() {
108 let line = line.trim_start_matches('\u{feff}').trim();
109 if line.is_empty() || line.starts_with('#') {
110 continue;
111 }
112 match line.split_once('=') {
113 Some((key, value)) => {
114 table.insert(key.trim().to_string(), value.trim().replace("\\n", "\n"));
115 }
116 None => eprintln!("audio-code: language {code}, line {}: expected `key = text`", number + 1),
117 }
118 }
119 }
120
121 pub fn insert(&mut self, code: &str, key: impl Into<String>, text: impl Into<String>) {
123 self.tables.entry(normalize(code)).or_default().insert(key.into(), text.into());
124 }
125
126 pub fn set(&mut self, code: &str) -> bool {
131 match self.resolve(code) {
132 Some(code) => {
133 self.current = code;
134 true
135 }
136 None => {
137 eprintln!("audio-code: no translation for language \"{code}\"");
138 false
139 }
140 }
141 }
142
143 pub fn current(&self) -> &str {
145 &self.current
146 }
147
148 pub fn fallback(&self) -> &str {
150 &self.fallback
151 }
152
153 pub fn set_fallback(&mut self, code: &str) {
155 self.fallback = normalize(code);
156 }
157
158 pub fn languages(&self) -> Vec<String> {
161 self.tables.keys().cloned().collect()
162 }
163
164 pub fn name(&self, code: &str) -> String {
166 let code = normalize(code);
167 self.tables.get(&code).and_then(|t| t.get("language")).cloned().unwrap_or(code)
168 }
169
170 pub fn has(&self, key: &str) -> bool {
172 self.lookup(key).is_some()
173 }
174
175 pub fn get(&self, key: &str) -> String {
178 match self.lookup(key) {
179 Some(text) => text.to_string(),
180 None => {
181 if self.missing.borrow_mut().insert(key.to_string()) {
182 eprintln!("audio-code: no text for \"{key}\" in language \"{}\"", self.current);
183 }
184 key.to_string()
185 }
186 }
187 }
188
189 pub fn format(&self, key: &str, args: &[(&str, &dyn Display)]) -> String {
199 fill(self.get(key), args)
200 }
201
202 pub fn plural(&self, key: &str, n: i64) -> String {
205 self.plural_with(key, n, &[])
206 }
207
208 pub fn plural_with(&self, key: &str, n: i64, args: &[(&str, &dyn Display)]) -> String {
210 let form = format!("{key}.{}", if n.abs() == 1 { "one" } else { "other" });
211 let text = if self.has(&form) { self.get(&form) } else { self.get(key) };
212 fill(fill(text, &[("n", &n)]), args)
213 }
214
215 pub fn system() -> Option<String> {
217 sys_locale::get_locale().map(|l| normalize(&l)).filter(|l| !l.is_empty())
218 }
219
220 pub fn direction_name(&self, degrees: f32) -> String {
222 const KEYS: [&str; 8] = [
223 "direction.north",
224 "direction.north_east",
225 "direction.east",
226 "direction.south_east",
227 "direction.south",
228 "direction.south_west",
229 "direction.west",
230 "direction.north_west",
231 ];
232 let i = ((crate::math::normalize_angle(degrees) + 22.5) / 45.0) as usize % 8;
233 self.get(KEYS[i])
234 }
235
236 pub fn describe_position(&self, from: Vec3, yaw: f32, target: Vec3) -> String {
239 let d = target - from;
240 let distance = self.plural("unit.meters", d.length().round() as i64);
241 if d.x.hypot(d.y) < 0.5 && d.z.abs() >= 0.5 {
242 let key = if d.z > 0.0 { "describe.directly_above" } else { "describe.directly_below" };
243 return self.format(key, &[("distance", &distance)]);
244 }
245 let hour = clock_position(angle_difference(yaw, bearing(from, target)));
246 let mut text = format!("{}, {distance}", self.format("describe.clock", &[("hour", &hour)]));
247 let height = d.z.round() as i64;
248 if height != 0 {
249 let key = if height > 0 { "describe.above" } else { "describe.below" };
250 let meters = self.plural("unit.meters", height.abs());
251 text.push_str(", ");
252 text.push_str(&self.format(key, &[("distance", &meters)]));
253 }
254 text
255 }
256
257 pub fn describe_speed(&self, vehicle: &Vehicle) -> String {
259 let speed = self.plural("unit.kmh", vehicle.speed_kmh().abs().round() as i64);
260 if vehicle.is_reversing() { self.format("vehicle.reversing", &[("speed", &speed)]) } else { speed }
261 }
262
263 fn lookup(&self, key: &str) -> Option<&str> {
264 let table = |code: &str| self.tables.get(code).and_then(|t| t.get(key)).map(String::as_str);
265 table(&self.current)
266 .or_else(|| table(&self.fallback))
267 .or_else(|| builtin(&self.current, key))
268 .or_else(|| builtin(&self.fallback, key))
269 .or_else(|| builtin("en", key))
270 }
271
272 pub(crate) fn resolve(&self, code: &str) -> Option<String> {
274 let code = normalize(code);
275 let main = code.split('-').next().unwrap_or_default().to_string();
276 let exists = |c: &str| self.tables.contains_key(c) || BUILTIN.contains(&c);
277 [code, main].into_iter().find(|c| exists(c))
278 }
279
280 pub(crate) fn resolve_loaded(&self, code: &str) -> Option<String> {
282 self.resolve(code).filter(|c| self.tables.contains_key(c))
283 }
284}
285
286fn normalize(code: &str) -> String {
288 let code = code.split(['.', '@']).next().unwrap_or_default();
289 code.trim().replace('_', "-").to_lowercase()
290}
291
292fn fill(mut text: String, args: &[(&str, &dyn Display)]) -> String {
293 for (name, value) in args {
294 let pattern = format!("{{{name}}}");
295 if text.contains(&pattern) {
296 text = text.replace(&pattern, &value.to_string());
297 }
298 }
299 text
300}
301
302const BUILTIN: [&str; 2] = ["en", "de"];
304
305fn builtin(code: &str, key: &str) -> Option<&'static str> {
307 let text = match code {
308 "en" => match key {
309 "ui.checked" => "checked",
310 "ui.not_checked" => "not checked",
311 "ui.blank" => "blank",
312 "ui.empty" => "empty",
313 "ui.edit" => "{label}, edit, {value}",
314 "ui.characters.one" => "1 character",
315 "ui.characters.other" => "{n} characters",
316 "ui.position" => "{index} of {count}",
317 "ui.yes" => "Yes",
318 "ui.no" => "No",
319 "ui.star" => "star",
320 "char.space" => "space",
321 "char.dot" => "dot",
322 "char.comma" => "comma",
323 "char.exclamation" => "exclamation",
324 "char.question" => "question",
325 "char.dash" => "dash",
326 "char.underscore" => "underscore",
327 "char.colon" => "colon",
328 "char.semicolon" => "semicolon",
329 "char.apostrophe" => "apostrophe",
330 "char.quote" => "quote",
331 "char.slash" => "slash",
332 "char.backslash" => "backslash",
333 "char.at" => "at",
334 "char.number" => "number",
335 "char.left_paren" => "left paren",
336 "char.right_paren" => "right paren",
337 "direction.north" => "north",
338 "direction.north_east" => "north east",
339 "direction.east" => "east",
340 "direction.south_east" => "south east",
341 "direction.south" => "south",
342 "direction.south_west" => "south west",
343 "direction.west" => "west",
344 "direction.north_west" => "north west",
345 "describe.clock" => "{hour} o'clock",
346 "describe.above" => "{distance} above",
347 "describe.below" => "{distance} below",
348 "describe.directly_above" => "directly above, {distance}",
349 "describe.directly_below" => "directly below, {distance}",
350 "unit.meters.one" => "1 meter",
351 "unit.meters.other" => "{n} meters",
352 "unit.kmh.one" => "1 kilometer per hour",
353 "unit.kmh.other" => "{n} kilometers per hour",
354 "vehicle.reversing" => "reversing, {speed}",
355 _ => return None,
356 },
357 "de" => match key {
358 "ui.checked" => "aktiviert",
359 "ui.not_checked" => "nicht aktiviert",
360 "ui.blank" => "leer",
361 "ui.empty" => "leer",
362 "ui.edit" => "{label}, Eingabefeld, {value}",
363 "ui.characters.one" => "1 Zeichen",
364 "ui.characters.other" => "{n} Zeichen",
365 "ui.position" => "{index} von {count}",
366 "ui.yes" => "Ja",
367 "ui.no" => "Nein",
368 "ui.star" => "Stern",
369 "char.space" => "Leerzeichen",
370 "char.dot" => "Punkt",
371 "char.comma" => "Komma",
372 "char.exclamation" => "Ausrufezeichen",
373 "char.question" => "Fragezeichen",
374 "char.dash" => "Bindestrich",
375 "char.underscore" => "Unterstrich",
376 "char.colon" => "Doppelpunkt",
377 "char.semicolon" => "Semikolon",
378 "char.apostrophe" => "Apostroph",
379 "char.quote" => "Anführungszeichen",
380 "char.slash" => "Schrägstrich",
381 "char.backslash" => "Backslash",
382 "char.at" => "at",
383 "char.number" => "Raute",
384 "char.left_paren" => "Klammer auf",
385 "char.right_paren" => "Klammer zu",
386 "direction.north" => "Norden",
387 "direction.north_east" => "Nordosten",
388 "direction.east" => "Osten",
389 "direction.south_east" => "Südosten",
390 "direction.south" => "Süden",
391 "direction.south_west" => "Südwesten",
392 "direction.west" => "Westen",
393 "direction.north_west" => "Nordwesten",
394 "describe.clock" => "{hour} Uhr",
395 "describe.above" => "{distance} darüber",
396 "describe.below" => "{distance} darunter",
397 "describe.directly_above" => "direkt darüber, {distance}",
398 "describe.directly_below" => "direkt darunter, {distance}",
399 "unit.meters.one" => "1 Meter",
400 "unit.meters.other" => "{n} Meter",
401 "unit.kmh.one" => "1 Kilometer pro Stunde",
402 "unit.kmh.other" => "{n} Kilometer pro Stunde",
403 "vehicle.reversing" => "rückwärts, {speed}",
404 _ => return None,
405 },
406 _ => return None,
407 };
408 Some(text)
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn lang() -> Lang {
416 let mut lang = Lang::new();
417 lang.add_text("en", "# comment\nlanguage = English\nhello = Hello, {name}!\ncoins.one = 1 coin\ncoins.other = {n} coins\nonly_en = fallback\n");
418 lang.add_text("de", "\u{feff}language = Deutsch\nhello = Hallo, {name}!\nlines = a\\nb\n");
419 lang
420 }
421
422 #[test]
423 fn looks_up_formats_and_falls_back() {
424 let mut lang = lang();
425 assert_eq!(lang.format("hello", &[("name", &"Anna")]), "Hello, Anna!");
426 assert!(lang.set("de_AT.UTF-8"));
427 assert_eq!(lang.current(), "de");
428 assert_eq!(lang.format("hello", &[("name", &"Anna")]), "Hallo, Anna!");
429 assert_eq!(lang.get("only_en"), "fallback");
430 assert_eq!(lang.get("lines"), "a\nb");
431 assert_eq!(lang.get("nothing"), "nothing");
432 assert!(!lang.set("fr"));
433 assert_eq!(lang.current(), "de");
434 }
435
436 #[test]
437 fn plurals_and_names() {
438 let lang = lang();
439 assert_eq!(lang.plural("coins", 1), "1 coin");
440 assert_eq!(lang.plural("coins", 4), "4 coins");
441 assert_eq!(lang.plural("hello", 4), "Hello, {name}!");
442 assert_eq!(lang.languages(), ["de", "en"]);
443 assert_eq!(lang.name("de"), "Deutsch");
444 assert_eq!(lang.name("fr"), "fr");
445 }
446
447 #[test]
448 fn engine_texts_are_built_in() {
449 let mut lang = Lang::new();
450 assert!(lang.set("de"));
451 assert_eq!(lang.get("ui.checked"), "aktiviert");
452 assert_eq!(lang.describe_position(Vec3::ZERO, 0.0, Vec3::new(5.0, 0.0, 0.0)), "3 Uhr, 5 Meter");
453 assert_eq!(lang.describe_position(Vec3::ZERO, 0.0, Vec3::new(0.0, 0.0, -1.0)), "direkt darunter, 1 Meter");
454 assert_eq!(lang.direction_name(180.0), "Süden");
455 lang.insert("de", "ui.checked", "an");
456 assert_eq!(lang.get("ui.checked"), "an", "game texts win over built in ones");
457 assert!(lang.languages() == ["de"]);
458 }
459}