Skip to main content

opys_bundle/
localized.rs

1//! Text a player reads, in one language or several.
2//!
3//! An option's title, subtitle, unit and placeholder and a choice's label are
4//! each a string or a map from a language to the string in it:
5//! `"RAM"` or `{ "en": "RAM", "uk": "Пам'ять" }`. A string is in the head's
6//! `locale`, and a map must hold that language, so whatever language a
7//! launcher asks for there is always something to show. Which language a
8//! player gets is the launcher's to choose; this says only what there is.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14/// Text a player reads. Kept as it was written, a string or a map, so that
15/// what is read back is what was written.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(try_from = "LocalizedWire", into = "LocalizedWire")]
18pub enum Localized {
19    /// In the head's `locale`.
20    Plain(String),
21    /// Language to text. Never empty, and every key is a language tag.
22    Translated(BTreeMap<String, String>),
23}
24
25impl Localized {
26    /// The languages a map names, and none for a string: that is in
27    /// whatever the head's `locale` is.
28    pub fn languages(&self) -> impl Iterator<Item = &str> {
29        let map = match self {
30            Localized::Plain(_) => None,
31            Localized::Translated(map) => Some(map.keys().map(String::as_str)),
32        };
33        map.into_iter().flatten()
34    }
35}
36
37impl From<&str> for Localized {
38    fn from(text: &str) -> Self {
39        Localized::Plain(text.to_owned())
40    }
41}
42
43/// Told apart by its JSON type, a string or an object, which is the whole
44/// difference between the two spellings.
45#[derive(Serialize, Deserialize)]
46#[serde(untagged)]
47pub(crate) enum LocalizedWire {
48    Plain(String),
49    Translated(BTreeMap<String, String>),
50}
51
52impl TryFrom<LocalizedWire> for Localized {
53    type Error = String;
54
55    fn try_from(raw: LocalizedWire) -> Result<Self, Self::Error> {
56        match raw {
57            LocalizedWire::Plain(text) => Ok(Localized::Plain(text)),
58            LocalizedWire::Translated(map) => {
59                if map.is_empty() {
60                    return Err("a translated text names no language".to_owned());
61                }
62                if let Some(bad) = map.keys().find(|tag| !is_language_tag(tag)) {
63                    return Err(format!("`{bad}` is not a language tag"));
64                }
65                Ok(Localized::Translated(map))
66            }
67        }
68    }
69}
70
71impl From<Localized> for LocalizedWire {
72    fn from(text: Localized) -> Self {
73        match text {
74            Localized::Plain(text) => LocalizedWire::Plain(text),
75            Localized::Translated(map) => LocalizedWire::Translated(map),
76        }
77    }
78}
79
80/// The shape of a BCP 47 tag, `uk` or `pt-BR`: a language of two or three
81/// letters, then subtags of one to eight letters or digits. Whether the
82/// language exists is not asked; a launcher that does not know it matches
83/// nothing and falls back to the head's `locale`.
84pub fn is_language_tag(tag: &str) -> bool {
85    let mut parts = tag.split('-');
86    let language = parts.next().unwrap_or_default();
87    (2..=3).contains(&language.len())
88        && language.bytes().all(|b| b.is_ascii_alphabetic())
89        && parts.all(|part| {
90            (1..=8).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_alphanumeric())
91        })
92}