Skip to main content

shuyi_ui/
text.rs

1//! Filling in a text's placeholders: `{name}` by a value the caller gives
2//! ([`fill_values`]), or by what the caller looks up for it, such as the
3//! key the player has for an action ([`fill`],
4//! [`Ctx::text_with`](crate::Ctx::text_with)).
5
6use crate::prelude::*;
7use alloc::collections::BTreeMap;
8use shuyi_assets::ContentError;
9use shuyi_assets::bundle::Bundle;
10use shuyi_assets::error::codes;
11use shuyi_assets::keymap::ActionSet;
12use shuyi_assets::palette::line_of_key;
13use shuyi_assets::text::NO_BREAK_SPACE;
14use shuyi_assets::tip::placeholders;
15
16use crate::input::help_keys::{HelpKeys, cursor_keys_name, key_name};
17
18/// The placeholder for the keys that move the cursor (`{Cursor}`).
19pub const CURSOR: &str = "Cursor";
20
21/// What a `{name}` placeholder that names a key says: [`{Cursor}`](CURSOR)
22/// the cursor keys, `{Confirm}` (an action's name) the key `keys` has for
23/// that action. `None` for any other name. What a game's text fills in by
24/// default ([`GameData::key_text`](crate::GameData::key_text)).
25pub fn key_text<A: ActionSet>(name: &str, keys: HelpKeys<'_, A>) -> Option<String> {
26    if name == CURSOR {
27        Some(cursor_keys_name(keys))
28    } else {
29        A::from_name(name).map(|action| key_name(keys, action))
30    }
31}
32use core::fmt::Display;
33
34/// Checks that every placeholder of screen text `text`, read from `path`
35/// in `bundle`, can be filled: a capitalized `{Name}` names a key, so it
36/// must be [`{Cursor}`](CURSOR) or an action of `A`, as [`key_text`]
37/// fills them. A lowercase `{name}` is a value a screen passes
38/// ([`Ctx::text_with`](crate::Ctx::text_with)'s `args`), which no file
39/// can know, and passes. Each error names the file, the key's line and
40/// the known actions, with code `SH2008` (`unknown-name`).
41///
42/// # Errors
43///
44/// Every placeholder that names no key, each its own error.
45pub fn check_key_placeholders<A: ActionSet>(
46    bundle: &Bundle,
47    path: &str,
48    text: &BTreeMap<String, String>,
49) -> Result<(), Vec<ContentError>> {
50    let source = bundle.file(path).unwrap_or_default();
51    let file = bundle.display_path(path);
52    let mut errors = Vec::new();
53    for (key, value) in text {
54        for name in placeholders(value) {
55            let names_a_key = name.chars().next().is_some_and(char::is_uppercase);
56            if !names_a_key || name == CURSOR || A::from_name(name).is_some() {
57                continue;
58            }
59            let known: Vec<&str> = A::ALL.iter().map(|a| a.name()).collect();
60            let message = format!(
61                "\"{key}\": unknown action \"{{{name}}}\"; known actions: {}, and {{{CURSOR}}} \
62                 for the cursor keys",
63                known.join(", ")
64            );
65            let mut error = ContentError::new(&file, message).with_code(codes::UNKNOWN_NAME);
66            if let Some(line) = line_of_key(source, key) {
67                error = error.at(line, None);
68            }
69            errors.push(error);
70        }
71    }
72    if errors.is_empty() {
73        Ok(())
74    } else {
75        Err(errors)
76    }
77}
78
79/// `text` with each `{name}` that `args` names replaced by its value, and
80/// nothing else: for text built where there are no keys to name (a line of
81/// a list, a label). Anything else in braces is left alone.
82pub fn fill_values(text: &str, args: &[(&str, &dyn Display)]) -> String {
83    fill(text, args, |_| None)
84}
85
86/// `text` with each `{name}` replaced by its value in `args`, else by what
87/// `lookup` gives for it, else left as written. What is filled in is not
88/// looked at again, so a value may hold braces. An unclosed `{` and what
89/// follows it are plain text.
90///
91/// What `lookup` gives (a key's name, `Left Shift`) is kept on one line:
92/// its spaces become [`NO_BREAK_SPACE`]s, which
93/// [`word_wrap`](crate::widgets::word_wrap) never breaks at and a glyph
94/// buffer draws as spaces.
95pub fn fill(
96    text: &str,
97    args: &[(&str, &dyn Display)],
98    lookup: impl Fn(&str) -> Option<String>,
99) -> String {
100    let mut out = String::with_capacity(text.len());
101    let mut rest = text;
102    while let Some((before, after)) = rest.split_once('{') {
103        out.push_str(before);
104        let Some((name, tail)) = after.split_once('}') else {
105            // An unclosed `{`: the rest is plain text.
106            out.push('{');
107            rest = after;
108            break;
109        };
110        if let Some((_, value)) = args.iter().find(|(arg, _)| *arg == name) {
111            out.push_str(&value.to_string());
112        } else if let Some(found) = lookup(name) {
113            out.extend(
114                found
115                    .chars()
116                    .map(|c| if c == ' ' { NO_BREAK_SPACE } else { c }),
117            );
118        } else {
119            out.push('{');
120            out.push_str(name);
121            out.push('}');
122        }
123        rest = tail;
124    }
125    out.push_str(rest);
126    out
127}
128
129/// Text a view holds before it is shown: a text key with the values of
130/// its placeholders, put in the player's language by the skin when it
131/// paints ([`Ctx::label`](crate::Ctx::label)), or text as it is. Logic that
132/// doesn't know the player's language builds views of keys, and they need
133/// no rebuilding when the language changes.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum Label {
136    /// The text for `key` in the player's language, with each `{name}` that
137    /// `args` names filled by its value (and keys' names filled as
138    /// [`Ctx::text_with`](crate::Ctx::text_with) fills them).
139    Key {
140        /// The text key.
141        key: String,
142        /// Each placeholder's name and its value, already text.
143        args: Vec<(String, String)>,
144    },
145    /// Text shown as it is: a name the player typed, a number, text the
146    /// caller has already translated.
147    Text(String),
148}
149
150impl Label {
151    /// The text for `key`, with no values.
152    pub fn key(key: impl Into<String>) -> Self {
153        Self::Key {
154            key: key.into(),
155            args: Vec::new(),
156        }
157    }
158
159    /// The same label with the placeholder `{name}` filled by `value`. A
160    /// [`Text`](Self::Text) label has no placeholders and stays as it is.
161    #[must_use]
162    pub fn with_arg(mut self, name: impl Into<String>, value: impl Display) -> Self {
163        if let Self::Key { args, .. } = &mut self {
164            args.push((name.into(), value.to_string()));
165        }
166        self
167    }
168}
169
170impl From<String> for Label {
171    fn from(text: String) -> Self {
172        Self::Text(text)
173    }
174}
175
176impl From<&str> for Label {
177    fn from(text: &str) -> Self {
178        Self::Text(text.to_owned())
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn upper(name: &str) -> Option<String> {
187        (name == "key").then(|| "K".to_owned())
188    }
189
190    #[test]
191    fn values_come_first_then_the_lookup_and_the_rest_stays() {
192        let text = "press {key} for {count} {things} {}";
193        assert_eq!(
194            fill(text, &[("count", &3)], upper),
195            "press K for 3 {things} {}"
196        );
197        // A value named like a looked-up placeholder wins.
198        assert_eq!(
199            fill(text, &[("key", &"Z"), ("things", &"apples")], upper),
200            "press Z for {count} apples {}"
201        );
202        assert_eq!(
203            fill_values(text, &[("key", &1)]),
204            "press 1 for {count} {things} {}"
205        );
206    }
207
208    #[test]
209    fn a_filled_value_is_not_filled_again() {
210        assert_eq!(fill("{a}{b}", &[("a", &"{b}")], upper), "{b}{b}");
211        assert_eq!(fill("{a}", &[("a", &"{key}")], upper), "{key}");
212    }
213
214    #[test]
215    fn a_key_name_of_two_words_is_never_split_across_lines() {
216        use shuyi_assets::font::CellWidths;
217        let key = |name: &str| (name == "Run").then(|| "Left Shift".to_owned());
218        let filled = fill("hold {Run} to run, {who}", &[("who", &"Ada Brook")], key);
219        // The key's name is joined; a value given is not.
220        assert_eq!(filled, "hold Left\u{a0}Shift to run, Ada Brook");
221        for width in 10..20 {
222            let lines = crate::widgets::word_wrap(&filled, width, &CellWidths::NARROW);
223            assert!(
224                lines.iter().any(|l| l.contains("Left\u{a0}Shift")),
225                "{width}: {lines:?}"
226            );
227        }
228    }
229
230    /// `check_key_placeholders` on `en.ron` written `source`, in `files/`.
231    fn checked(source: &'static str) -> Result<(), Vec<ContentError>> {
232        use shuyi_assets::bundle::Layer;
233        use shuyi_assets::keymap::UiAction;
234        let files: &'static [(&str, &[u8])] = Box::leak(Box::new([("en.ron", source.as_bytes())]));
235        let bundle = Bundle::new(vec![Layer::table("files", files)]);
236        let text: shuyi_assets::lang::English = ron::from_str(source).unwrap();
237        check_key_placeholders::<UiAction>(&bundle, "en.ron", &text.0)
238    }
239
240    #[test]
241    fn actions_the_cursor_and_values_a_screen_passes_are_placeholders() {
242        let good = "{\n  \"a\": \"{Confirm} plant {seeds} {Cursor} move\",\n  \"b\": \"{} {\",\n}";
243        assert_eq!(checked(good), Ok(()));
244    }
245
246    #[test]
247    fn a_misspelled_action_fails_with_its_file_line_and_the_known_actions() {
248        let bad = "{\n  \"a\": \"{Confirm} ok\",\n  \"title.help\": \"{Confirn} plant\",\n}";
249        let errors = checked(bad).unwrap_err();
250        assert_eq!(errors.len(), 1, "{errors:?}");
251        assert_eq!(errors[0].code, Some(codes::UNKNOWN_NAME));
252        assert_eq!(
253            errors[0].to_string(),
254            "files/en.ron:3: \"title.help\": unknown action \"{Confirn}\"; known actions: \
255             Up, Down, Left, Right, Confirm, Cancel, and {Cursor} for the cursor keys"
256        );
257    }
258
259    #[test]
260    fn an_unclosed_brace_is_plain_text() {
261        assert_eq!(fill("a {key", &[], upper), "a {key");
262        assert_eq!(fill("{key} {", &[], upper), "K {");
263        assert_eq!(fill("} {key}", &[], upper), "} K");
264        assert_eq!(fill("", &[], upper), "");
265        assert_eq!(fill("no braces", &[], upper), "no braces");
266    }
267}