Skip to main content

shuyi_ui/widgets/
name_entry.rs

1//! Typing a name: a player profile's, a hero's, a farm's.
2//!
3//! On a keyboard the player **types** it ([`NameBox`]). While the box is
4//! open the game's keys do nothing (its letters are letters then); the
5//! text box's own fixed keys finish, delete and cancel ([`text_key`]).
6//! With a controller, or on a device with no keyboard, it is spelled on a
7//! letter grid instead ([`NameEntry`]) with the cursor and Confirm, like
8//! a console's naming screen.
9//!
10//! Name rules for both ([`NameRules`]): letters (A-Z, a-z), `-`, `'`,
11//! digits where the rules allow them, and single spaces between words, at
12//! most so many characters, not blank.
13//!
14//! Neither decides what Cancel means: they report it ([`NameEvent::Cancel`])
15//! and the screen closes the box, or asks first. The name is the screen's
16//! to take only on [`NameEvent::Done`].
17//!
18//! **The look is a skin**: [`TypingView`] and [`GridView`] say what a box
19//! shows, and [`glyph`] paints them.
20
21pub mod glyph;
22
23use crate::prelude::*;
24
25use shuyi_assets::keymap::{ActionSet, UiAction};
26
27use crate::audio::MenuSound;
28use crate::ctx::{Ctx, GameData};
29use crate::input::fixed::{TextKey, text_key};
30use crate::screen::FrameInput;
31
32/// The text key of the name grid's cell that types a space.
33pub const SPACE: &str = "name_entry.grid.blank";
34/// The text key of the cell that deletes the last letter.
35pub const DELETE: &str = "name_entry.grid.delete";
36/// The text key of the cell that closes the name grid.
37pub const DONE: &str = "name_entry.grid.done";
38/// The text key of the typing box's hint.
39pub const TYPE_HINT: &str = "name_entry.type_hint";
40/// The text key of the help line while the name grid is open.
41pub const HELP_GRID: &str = "name_entry.help.grid";
42
43/// What a name may hold.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct NameRules {
46    /// The most characters.
47    pub max_chars: usize,
48    /// Whether digits are allowed (and the grid has a row of them).
49    pub digits: bool,
50}
51
52impl NameRules {
53    /// Whether `c` may go at the end of `name`.
54    fn allows(self, name: &str, c: char) -> bool {
55        match c {
56            _ if name.chars().count() >= self.max_chars => false,
57            ' ' => !name.is_empty() && !name.ends_with(' '),
58            c if c.is_ascii_digit() => self.digits,
59            c => c.is_ascii_alphabetic() || c == '-' || c == '\'',
60        }
61    }
62}
63
64/// Adds `c` to `name` if `rules` allow it.
65fn add_char(name: &mut String, c: char, rules: NameRules) -> bool {
66    let ok = rules.allows(name, c);
67    if ok {
68        name.push(c);
69    }
70    ok
71}
72
73/// `name` finished: trimmed, or `None` if blank.
74pub fn finished(name: &str) -> Option<String> {
75    let name = name.trim();
76    (!name.is_empty()).then(|| name.to_owned())
77}
78
79/// What a key did in a name box or the name grid.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum NameEvent {
82    /// Still typing.
83    Open,
84    /// The player kept the name: it is [`finished`], not blank.
85    Done,
86    /// The player asked to close it without keeping the name.
87    Cancel,
88}
89
90/// The name being typed on the keyboard.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct NameBox {
93    /// The name typed so far.
94    name: String,
95    rules: NameRules,
96}
97
98impl NameBox {
99    /// The box for `name`, which the player types on from.
100    pub fn new(name: &str, rules: NameRules) -> Self {
101        Self {
102            name: name.to_owned(),
103            rules,
104        }
105    }
106
107    /// The name typed so far.
108    pub fn name(&self) -> &str {
109        &self.name
110    }
111
112    /// One frame: the characters typed, then the text box's keys. Actions
113    /// are ignored. A character the rules refuse sounds denied; typing
114    /// makes no sound. Cancel makes none either: it is the screen's.
115    pub fn handle<G: GameData>(
116        &mut self,
117        input: &FrameInput<G::Action>,
118        ctx: &mut Ctx<G>,
119    ) -> NameEvent {
120        for &c in input.text() {
121            if !add_char(&mut self.name, c, self.rules) {
122                ctx.audio.menu(MenuSound::Denied);
123            }
124        }
125        for &chord in input.pressed_chords() {
126            match text_key(chord) {
127                Some(TextKey::Done) => match finished(&self.name) {
128                    Some(name) => {
129                        self.name = name;
130                        ctx.audio.menu(MenuSound::Select);
131                        return NameEvent::Done;
132                    }
133                    None => ctx.audio.menu(MenuSound::Denied),
134                },
135                Some(TextKey::Delete) => {
136                    if self.name.pop().is_some() {
137                        ctx.audio.menu(MenuSound::Cancel);
138                    }
139                }
140                Some(TextKey::Cancel) => return NameEvent::Cancel,
141                None => {}
142            }
143        }
144        NameEvent::Open
145    }
146
147    /// What the box shows, titled `title`, for a skin to paint.
148    pub fn view<G: GameData>(&self, ctx: &Ctx<G>, title: &str) -> TypingView {
149        TypingView {
150            title: title.to_owned(),
151            typed: self.name.clone(),
152            hint: ctx.text(TYPE_HINT).to_owned(),
153        }
154    }
155}
156
157/// The letters of the name grid, one row each.
158// check-text: not player text
159const LETTER_ROWS: [&str; 4] = [
160    "ABCDEFGHIJKLM",
161    "NOPQRSTUVWXYZ",
162    "abcdefghijklm",
163    "nopqrstuvwxyz",
164];
165
166/// The grid's row of digits, when the rules allow them.
167// check-text: not player text
168const DIGIT_ROW: &str = "0123456789";
169
170/// One cell of the name grid.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum GridCell {
173    /// Types this character.
174    Char(char),
175    /// Types a space (between two words).
176    Space,
177    /// Deletes the last character.
178    Delete,
179    /// Closes the grid, keeping the name.
180    Done,
181}
182
183impl GridCell {
184    /// Text shown for the cell, in `ctx`'s language.
185    pub fn label<G: GameData>(self, ctx: &Ctx<G>) -> String {
186        let key = match self {
187            GridCell::Char(c) => return c.to_string(),
188            GridCell::Space => SPACE,
189            GridCell::Delete => DELETE,
190            GridCell::Done => DONE,
191        };
192        ctx.text(key).to_owned()
193    }
194}
195
196/// The name grid's rows: four rows of letters, a row of digits if `rules`
197/// allow them, then `-`, `'`, Blank, Delete and Done.
198pub fn grid(rules: NameRules) -> Vec<Vec<GridCell>> {
199    let chars = |row: &str| row.chars().map(GridCell::Char).collect();
200    let mut rows: Vec<Vec<GridCell>> = LETTER_ROWS.iter().map(|r| chars(r)).collect();
201    if rules.digits {
202        rows.push(chars(DIGIT_ROW));
203    }
204    rows.push(vec![
205        GridCell::Char('-'),
206        GridCell::Char('\''),
207        GridCell::Space,
208        GridCell::Delete,
209        GridCell::Done,
210    ]);
211    rows
212}
213
214/// The name grid (a controller, or a device with no keyboard).
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct NameEntry {
217    /// The name being spelled.
218    name: String,
219    /// The focused cell: row, column.
220    at: (usize, usize),
221    rules: NameRules,
222}
223
224impl NameEntry {
225    /// What the grid reacts to ([`handle`](Self::handle)), for the screen
226    /// that shows it to declare
227    /// ([`Screen::actions`](crate::screen::Screen::actions)).
228    pub const ACTIONS: &'static [UiAction] = crate::input::NAVIGATION;
229
230    /// The grid for `name`, on its first letter.
231    pub fn new(name: &str, rules: NameRules) -> Self {
232        Self {
233            name: name.to_owned(),
234            at: (0, 0),
235            rules,
236        }
237    }
238
239    /// The name spelled so far.
240    pub fn name(&self) -> &str {
241        &self.name
242    }
243
244    /// The focused cell: row, column.
245    pub fn focus(&self) -> (usize, usize) {
246        self.at
247    }
248
249    /// Handles `action`: the cursor keys move over the grid (wrapping along
250    /// a row, keeping to the last cell of a shorter row), Confirm uses the
251    /// focused cell, Cancel deletes the last character (on an empty name it
252    /// asks to close the grid). Plays the menu sounds, but not Cancel's
253    /// closing one: that is the screen's.
254    pub fn handle<G: GameData>(&mut self, action: G::Action, ctx: &mut Ctx<G>) -> NameEvent {
255        let rows = grid(self.rules);
256        let (row, col) = self.at;
257        let width = |r: usize| rows[r].len();
258        let moved = match action.ui() {
259            Some(UiAction::Left) => (row, (col + width(row) - 1) % width(row)),
260            Some(UiAction::Right) => (row, (col + 1) % width(row)),
261            Some(UiAction::Up) => {
262                let r = (row + rows.len() - 1) % rows.len();
263                (r, col.min(width(r) - 1))
264            }
265            Some(UiAction::Down) => {
266                let r = (row + 1) % rows.len();
267                (r, col.min(width(r) - 1))
268            }
269            Some(UiAction::Confirm) => return self.confirm(rows[row][col], ctx),
270            Some(UiAction::Cancel) => {
271                if self.name.pop().is_some() {
272                    ctx.audio.menu(MenuSound::Cancel);
273                    return NameEvent::Open;
274                }
275                return NameEvent::Cancel;
276            }
277            Some(UiAction::Debug) | None => return NameEvent::Open,
278        };
279        if moved != self.at {
280            self.at = moved;
281            ctx.audio.menu(MenuSound::Move);
282        }
283        NameEvent::Open
284    }
285
286    /// Confirm on `cell`. A character the rules refuse (past the most
287    /// characters, a space at the start or after another) and Done on a
288    /// blank name are refused.
289    fn confirm<G: GameData>(&mut self, cell: GridCell, ctx: &mut Ctx<G>) -> NameEvent {
290        let ok = match cell {
291            GridCell::Char(c) => add_char(&mut self.name, c, self.rules),
292            GridCell::Space => add_char(&mut self.name, ' ', self.rules),
293            GridCell::Delete => self.name.pop().is_some(),
294            GridCell::Done => {
295                if let Some(name) = finished(&self.name) {
296                    self.name = name;
297                    ctx.audio.menu(MenuSound::Select);
298                    return NameEvent::Done;
299                }
300                false
301            }
302        };
303        ctx.audio.menu(if ok {
304            MenuSound::Select
305        } else {
306            MenuSound::Denied
307        });
308        NameEvent::Open
309    }
310
311    /// What the grid shows, titled `title`, for a skin to paint.
312    pub fn view<G: GameData>(&self, ctx: &Ctx<G>, title: &str) -> GridView {
313        GridView {
314            title: title.to_owned(),
315            spelled: self.name.clone(),
316            rows: grid(self.rules)
317                .iter()
318                .map(|row| row.iter().map(|g| g.label(ctx)).collect())
319                .collect(),
320            focus: self.at,
321        }
322    }
323}
324
325/// The typing box.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct TypingView {
328    /// The box's title.
329    pub title: String,
330    /// The name typed so far (without a cursor: that is the skin's).
331    pub typed: String,
332    /// What to do: type.
333    pub hint: String,
334}
335
336/// The letter grid.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct GridView {
339    /// The grid's title.
340    pub title: String,
341    /// The name spelled so far (without a cursor: that is the skin's).
342    pub spelled: String,
343    /// The cells, row by row, as their labels.
344    pub rows: Vec<Vec<String>>,
345    /// The focused cell: row, column.
346    pub focus: (usize, usize),
347}
348
349#[cfg(test)]
350mod tests;