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::net::Reason;
50use crate::vehicle::Vehicle;
51
52#[derive(Debug, Clone)]
54pub struct Lang {
55 current: String,
56 fallback: String,
57 tables: BTreeMap<String, HashMap<String, String>>,
58 missing: RefCell<HashSet<String>>,
60}
61
62impl Default for Lang {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl Lang {
69 pub fn new() -> Self {
71 Self {
72 current: "en".into(),
73 fallback: "en".into(),
74 tables: BTreeMap::new(),
75 missing: RefCell::default(),
76 }
77 }
78
79 pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> usize {
82 let Ok(entries) = std::fs::read_dir(dir.as_ref()) else {
83 return 0;
84 };
85 let mut count = 0;
86 for path in entries.flatten().map(|e| e.path()) {
87 if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("txt")) {
88 continue;
89 }
90 let Some(code) = path.file_stem().and_then(|s| s.to_str()) else {
91 continue;
92 };
93 match std::fs::read_to_string(&path) {
94 Ok(text) => {
95 self.add_text(code, &text);
96 count += 1;
97 }
98 Err(e) => eprintln!("audio-code: could not read {}: {e}", path.display()),
99 }
100 }
101 count
102 }
103
104 pub fn add_text(&mut self, code: &str, text: &str) {
107 let table = self.tables.entry(normalize(code)).or_default();
108 for (number, line) in text.lines().enumerate() {
109 let line = line.trim_start_matches('\u{feff}').trim();
110 if line.is_empty() || line.starts_with('#') {
111 continue;
112 }
113 match line.split_once('=') {
114 Some((key, value)) => {
115 table.insert(key.trim().to_string(), value.trim().replace("\\n", "\n"));
116 }
117 None => eprintln!("audio-code: language {code}, line {}: expected `key = text`", number + 1),
118 }
119 }
120 }
121
122 pub fn insert(&mut self, code: &str, key: impl Into<String>, text: impl Into<String>) {
124 self.tables.entry(normalize(code)).or_default().insert(key.into(), text.into());
125 }
126
127 pub fn set(&mut self, code: &str) -> bool {
132 match self.resolve(code) {
133 Some(code) => {
134 self.current = code;
135 true
136 }
137 None => {
138 eprintln!("audio-code: no translation for language \"{code}\"");
139 false
140 }
141 }
142 }
143
144 pub fn current(&self) -> &str {
146 &self.current
147 }
148
149 pub fn fallback(&self) -> &str {
151 &self.fallback
152 }
153
154 pub fn set_fallback(&mut self, code: &str) {
156 self.fallback = normalize(code);
157 }
158
159 pub fn languages(&self) -> Vec<String> {
162 self.tables.keys().cloned().collect()
163 }
164
165 pub fn name(&self, code: &str) -> String {
167 let code = normalize(code);
168 self.tables.get(&code).and_then(|t| t.get("language")).cloned().unwrap_or(code)
169 }
170
171 pub fn has(&self, key: &str) -> bool {
173 self.lookup(key).is_some()
174 }
175
176 pub fn get(&self, key: &str) -> String {
179 match self.lookup(key) {
180 Some(text) => text.to_string(),
181 None => {
182 if self.missing.borrow_mut().insert(key.to_string()) {
183 eprintln!("audio-code: no text for \"{key}\" in language \"{}\"", self.current);
184 }
185 key.to_string()
186 }
187 }
188 }
189
190 pub fn format(&self, key: &str, args: &[(&str, &dyn Display)]) -> String {
200 fill(self.get(key), args)
201 }
202
203 pub fn plural(&self, key: &str, n: i64) -> String {
206 self.plural_with(key, n, &[])
207 }
208
209 pub fn plural_with(&self, key: &str, n: i64, args: &[(&str, &dyn Display)]) -> String {
211 let form = format!("{key}.{}", if n.abs() == 1 { "one" } else { "other" });
212 let text = if self.has(&form) { self.get(&form) } else { self.get(key) };
213 fill(fill(text, &[("n", &n)]), args)
214 }
215
216 pub fn system() -> Option<String> {
218 sys_locale::get_locale().map(|l| normalize(&l)).filter(|l| !l.is_empty())
219 }
220
221 pub fn direction_name(&self, degrees: f32) -> String {
223 const KEYS: [&str; 8] = [
224 "direction.north",
225 "direction.north_east",
226 "direction.east",
227 "direction.south_east",
228 "direction.south",
229 "direction.south_west",
230 "direction.west",
231 "direction.north_west",
232 ];
233 let i = ((crate::math::normalize_angle(degrees) + 22.5) / 45.0) as usize % 8;
234 self.get(KEYS[i])
235 }
236
237 pub fn describe_position(&self, from: Vec3, yaw: f32, target: Vec3) -> String {
240 let d = target - from;
241 let distance = self.plural("unit.meters", d.length().round() as i64);
242 if d.x.hypot(d.y) < 0.5 && d.z.abs() >= 0.5 {
243 let key = if d.z > 0.0 { "describe.directly_above" } else { "describe.directly_below" };
244 return self.format(key, &[("distance", &distance)]);
245 }
246 let hour = clock_position(angle_difference(yaw, bearing(from, target)));
247 let mut text = format!("{}, {distance}", self.format("describe.clock", &[("hour", &hour)]));
248 let height = d.z.round() as i64;
249 if height != 0 {
250 let key = if height > 0 { "describe.above" } else { "describe.below" };
251 let meters = self.plural("unit.meters", height.abs());
252 text.push_str(", ");
253 text.push_str(&self.format(key, &[("distance", &meters)]));
254 }
255 text
256 }
257
258 pub fn describe_speed(&self, vehicle: &Vehicle) -> String {
260 let speed = self.plural("unit.kmh", vehicle.speed_kmh().abs().round() as i64);
261 if vehicle.is_reversing() { self.format("vehicle.reversing", &[("speed", &speed)]) } else { speed }
262 }
263
264 pub fn describe_reason(&self, reason: &Reason) -> String {
276 match reason {
277 Reason::Left => self.get("net.left"),
278 Reason::Closed => self.get("net.closed"),
279 Reason::Kicked(text) if text.is_empty() => self.get("net.kicked"),
280 Reason::Kicked(text) => self.format("net.kicked_reason", &[("reason", text)]),
281 Reason::ServerFull => self.get("net.server_full"),
282 Reason::WrongGame(game) => self.format("net.wrong_game", &[("game", game)]),
283 Reason::WrongVersion(version) => self.format("net.wrong_version", &[("version", version)]),
284 Reason::TimedOut => self.get("net.timed_out"),
285 Reason::Failed(error) => self.format("net.failed", &[("error", error)]),
286 Reason::Lost(error) => self.format("net.lost", &[("error", error)]),
287 }
288 }
289
290 fn lookup(&self, key: &str) -> Option<&str> {
291 let table = |code: &str| self.tables.get(code).and_then(|t| t.get(key)).map(String::as_str);
292 table(&self.current)
293 .or_else(|| table(&self.fallback))
294 .or_else(|| builtin(&self.current, key))
295 .or_else(|| builtin(&self.fallback, key))
296 .or_else(|| builtin("en", key))
297 }
298
299 pub(crate) fn resolve(&self, code: &str) -> Option<String> {
301 let code = normalize(code);
302 let main = code.split('-').next().unwrap_or_default().to_string();
303 let exists = |c: &str| self.tables.contains_key(c) || BUILTIN.contains(&c);
304 [code, main].into_iter().find(|c| exists(c))
305 }
306
307 pub(crate) fn resolve_loaded(&self, code: &str) -> Option<String> {
309 self.resolve(code).filter(|c| self.tables.contains_key(c))
310 }
311}
312
313fn normalize(code: &str) -> String {
315 let code = code.split(['.', '@']).next().unwrap_or_default();
316 code.trim().replace('_', "-").to_lowercase()
317}
318
319fn fill(mut text: String, args: &[(&str, &dyn Display)]) -> String {
320 for (name, value) in args {
321 let pattern = format!("{{{name}}}");
322 if text.contains(&pattern) {
323 text = text.replace(&pattern, &value.to_string());
324 }
325 }
326 text
327}
328
329const BUILTIN: [&str; 2] = ["en", "de"];
331
332fn builtin(code: &str, key: &str) -> Option<&'static str> {
334 let text = match code {
335 "en" => match key {
336 "ui.checked" => "checked",
337 "ui.not_checked" => "not checked",
338 "ui.blank" => "blank",
339 "ui.empty" => "empty",
340 "ui.edit" => "{label}, edit, {value}",
341 "ui.characters.one" => "1 character",
342 "ui.characters.other" => "{n} characters",
343 "ui.position" => "{index} of {count}",
344 "ui.yes" => "Yes",
345 "ui.no" => "No",
346 "ui.star" => "star",
347 "char.space" => "space",
348 "char.dot" => "dot",
349 "char.comma" => "comma",
350 "char.exclamation" => "exclamation",
351 "char.question" => "question",
352 "char.dash" => "dash",
353 "char.underscore" => "underscore",
354 "char.colon" => "colon",
355 "char.semicolon" => "semicolon",
356 "char.apostrophe" => "apostrophe",
357 "char.quote" => "quote",
358 "char.slash" => "slash",
359 "char.backslash" => "backslash",
360 "char.at" => "at",
361 "char.number" => "number",
362 "char.left_paren" => "left paren",
363 "char.right_paren" => "right paren",
364 "direction.north" => "north",
365 "direction.north_east" => "north east",
366 "direction.east" => "east",
367 "direction.south_east" => "south east",
368 "direction.south" => "south",
369 "direction.south_west" => "south west",
370 "direction.west" => "west",
371 "direction.north_west" => "north west",
372 "describe.clock" => "{hour} o'clock",
373 "describe.above" => "{distance} above",
374 "describe.below" => "{distance} below",
375 "describe.directly_above" => "directly above, {distance}",
376 "describe.directly_below" => "directly below, {distance}",
377 "unit.meters.one" => "1 meter",
378 "unit.meters.other" => "{n} meters",
379 "unit.kmh.one" => "1 kilometer per hour",
380 "unit.kmh.other" => "{n} kilometers per hour",
381 "vehicle.reversing" => "reversing, {speed}",
382 "net.left" => "Left the game",
383 "net.closed" => "The server was closed",
384 "net.kicked" => "Kicked from the server",
385 "net.kicked_reason" => "Kicked from the server: {reason}",
386 "net.server_full" => "The server is full",
387 "net.wrong_game" => "This server runs another game: {game}",
388 "net.wrong_version" => "Wrong game version. The server has version {version}",
389 "net.timed_out" => "The connection timed out",
390 "net.failed" => "Could not connect: {error}",
391 "net.lost" => "Connection lost: {error}",
392 _ => return None,
393 },
394 "de" => match key {
395 "ui.checked" => "aktiviert",
396 "ui.not_checked" => "nicht aktiviert",
397 "ui.blank" => "leer",
398 "ui.empty" => "leer",
399 "ui.edit" => "{label}, Eingabefeld, {value}",
400 "ui.characters.one" => "1 Zeichen",
401 "ui.characters.other" => "{n} Zeichen",
402 "ui.position" => "{index} von {count}",
403 "ui.yes" => "Ja",
404 "ui.no" => "Nein",
405 "ui.star" => "Stern",
406 "char.space" => "Leerzeichen",
407 "char.dot" => "Punkt",
408 "char.comma" => "Komma",
409 "char.exclamation" => "Ausrufezeichen",
410 "char.question" => "Fragezeichen",
411 "char.dash" => "Bindestrich",
412 "char.underscore" => "Unterstrich",
413 "char.colon" => "Doppelpunkt",
414 "char.semicolon" => "Semikolon",
415 "char.apostrophe" => "Apostroph",
416 "char.quote" => "Anführungszeichen",
417 "char.slash" => "Schrägstrich",
418 "char.backslash" => "Backslash",
419 "char.at" => "at",
420 "char.number" => "Raute",
421 "char.left_paren" => "Klammer auf",
422 "char.right_paren" => "Klammer zu",
423 "direction.north" => "Norden",
424 "direction.north_east" => "Nordosten",
425 "direction.east" => "Osten",
426 "direction.south_east" => "Südosten",
427 "direction.south" => "Süden",
428 "direction.south_west" => "Südwesten",
429 "direction.west" => "Westen",
430 "direction.north_west" => "Nordwesten",
431 "describe.clock" => "{hour} Uhr",
432 "describe.above" => "{distance} darüber",
433 "describe.below" => "{distance} darunter",
434 "describe.directly_above" => "direkt darüber, {distance}",
435 "describe.directly_below" => "direkt darunter, {distance}",
436 "unit.meters.one" => "1 Meter",
437 "unit.meters.other" => "{n} Meter",
438 "unit.kmh.one" => "1 Kilometer pro Stunde",
439 "unit.kmh.other" => "{n} Kilometer pro Stunde",
440 "vehicle.reversing" => "rückwärts, {speed}",
441 "net.left" => "Hat das Spiel verlassen",
442 "net.closed" => "Der Server wurde geschlossen",
443 "net.kicked" => "Vom Server geworfen",
444 "net.kicked_reason" => "Vom Server geworfen: {reason}",
445 "net.server_full" => "Der Server ist voll",
446 "net.wrong_game" => "Auf diesem Server läuft ein anderes Spiel: {game}",
447 "net.wrong_version" => "Falsche Spielversion. Der Server hat Version {version}",
448 "net.timed_out" => "Zeitüberschreitung der Verbindung",
449 "net.failed" => "Verbindung fehlgeschlagen: {error}",
450 "net.lost" => "Verbindung verloren: {error}",
451 _ => return None,
452 },
453 _ => return None,
454 };
455 Some(text)
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn lang() -> Lang {
463 let mut lang = Lang::new();
464 lang.add_text("en", "# comment\nlanguage = English\nhello = Hello, {name}!\ncoins.one = 1 coin\ncoins.other = {n} coins\nonly_en = fallback\n");
465 lang.add_text("de", "\u{feff}language = Deutsch\nhello = Hallo, {name}!\nlines = a\\nb\n");
466 lang
467 }
468
469 #[test]
470 fn looks_up_formats_and_falls_back() {
471 let mut lang = lang();
472 assert_eq!(lang.format("hello", &[("name", &"Anna")]), "Hello, Anna!");
473 assert!(lang.set("de_AT.UTF-8"));
474 assert_eq!(lang.current(), "de");
475 assert_eq!(lang.format("hello", &[("name", &"Anna")]), "Hallo, Anna!");
476 assert_eq!(lang.get("only_en"), "fallback");
477 assert_eq!(lang.get("lines"), "a\nb");
478 assert_eq!(lang.get("nothing"), "nothing");
479 assert!(!lang.set("fr"));
480 assert_eq!(lang.current(), "de");
481 }
482
483 #[test]
484 fn plurals_and_names() {
485 let lang = lang();
486 assert_eq!(lang.plural("coins", 1), "1 coin");
487 assert_eq!(lang.plural("coins", 4), "4 coins");
488 assert_eq!(lang.plural("hello", 4), "Hello, {name}!");
489 assert_eq!(lang.languages(), ["de", "en"]);
490 assert_eq!(lang.name("de"), "Deutsch");
491 assert_eq!(lang.name("fr"), "fr");
492 }
493
494 #[test]
495 fn engine_texts_are_built_in() {
496 let mut lang = Lang::new();
497 assert!(lang.set("de"));
498 assert_eq!(lang.get("ui.checked"), "aktiviert");
499 assert_eq!(lang.describe_position(Vec3::ZERO, 0.0, Vec3::new(5.0, 0.0, 0.0)), "3 Uhr, 5 Meter");
500 assert_eq!(lang.describe_position(Vec3::ZERO, 0.0, Vec3::new(0.0, 0.0, -1.0)), "direkt darunter, 1 Meter");
501 assert_eq!(lang.describe_reason(&Reason::ServerFull), "Der Server ist voll");
502 assert_eq!(lang.describe_reason(&Reason::Kicked("Spam".into())), "Vom Server geworfen: Spam");
503 assert_eq!(lang.direction_name(180.0), "Süden");
504 lang.insert("de", "ui.checked", "an");
505 assert_eq!(lang.get("ui.checked"), "an", "game texts win over built in ones");
506 assert!(lang.languages() == ["de"]);
507 }
508}