Skip to main content

audio_code/ui/
dialog.rs

1//! Message boxes, yes/no questions and text input.
2
3use super::{Status, UiContext, UiEvent, UiSound, Value, Widget};
4use crate::input::Key;
5
6/// A message the player reads and dismisses with Enter, Space or Escape.
7/// Up/Down/Home/End review the text line by line.
8#[derive(Debug, Clone)]
9pub struct Message {
10    id: String,
11    text: String,
12    lines: Vec<String>,
13    line: usize,
14}
15
16impl Message {
17    /// Creates a message.
18    pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
19        let text = text.into();
20        let lines = text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect();
21        Self { id: id.into(), text, lines, line: 0 }
22    }
23}
24
25impl Widget for Message {
26    fn id(&self) -> &str {
27        &self.id
28    }
29
30    fn open(&mut self, ui: &mut UiContext<'_>) {
31        ui.play(UiSound::Open, None);
32        ui.speech.speak(&self.text);
33    }
34
35    fn update(&mut self, ui: &mut UiContext<'_>) -> Status {
36        let last = self.lines.len().saturating_sub(1);
37        let target = if ui.key(Key::Up) {
38            Some(self.line.saturating_sub(1))
39        } else if ui.key(Key::Down) {
40            Some((self.line + 1).min(last))
41        } else if ui.key(Key::Home) {
42            Some(0)
43        } else if ui.key(Key::End) {
44            Some(last)
45        } else {
46            None
47        };
48        if let Some(t) = target {
49            ui.play(if t == self.line { UiSound::Edge } else { UiSound::Navigate }, None);
50            self.line = t;
51            if let Some(line) = self.lines.get(t) {
52                ui.speech.speak(line);
53            }
54        }
55        if ui.key(Key::Enter) || ui.key(Key::NumpadEnter) || ui.input.pressed(Key::Space) || ui.key(Key::Escape) {
56            ui.play(UiSound::Close, None);
57            ui.emit(UiEvent::Closed { id: self.id.clone() });
58            return Status::Close;
59        }
60        Status::Open
61    }
62}
63
64/// A yes/no question. Arrows switch the answer, Enter confirms, Escape answers no.
65/// The first letters of the answers work as shortcuts.
66#[derive(Debug, Clone)]
67pub struct Confirm {
68    id: String,
69    question: String,
70    /// Answer texts; `None` uses `ui.yes` / `ui.no` of the current language.
71    yes: Option<String>,
72    no: Option<String>,
73    selected_yes: bool,
74}
75
76impl Confirm {
77    /// Creates a question with "Yes" and "No" answers (in the current language, see
78    /// [`crate::lang`]), "Yes" focused.
79    pub fn new(id: impl Into<String>, question: impl Into<String>) -> Self {
80        Self { id: id.into(), question: question.into(), yes: None, no: None, selected_yes: true }
81    }
82
83    /// Changes the answer texts, e.g. `.labels("Ja", "Nein")`.
84    pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
85        self.yes = Some(yes.into());
86        self.no = Some(no.into());
87        self
88    }
89
90    /// Focus "No" initially (for dangerous actions).
91    pub fn default_no(mut self) -> Self {
92        self.selected_yes = false;
93        self
94    }
95
96    fn label(&self, yes: bool, ui: &UiContext<'_>) -> String {
97        let (custom, key) = if yes { (&self.yes, "ui.yes") } else { (&self.no, "ui.no") };
98        custom.clone().unwrap_or_else(|| ui.lang.get(key))
99    }
100
101    fn current(&self, ui: &UiContext<'_>) -> String {
102        self.label(self.selected_yes, ui)
103    }
104
105    fn answer(&self, yes: bool, ui: &mut UiContext<'_>) -> Status {
106        ui.play(if yes { UiSound::Activate } else { UiSound::Close }, None);
107        ui.emit(UiEvent::Confirmed { id: self.id.clone(), yes });
108        Status::Close
109    }
110}
111
112fn first_lower(s: &str) -> Option<char> {
113    s.chars().next().and_then(|c| c.to_lowercase().next())
114}
115
116impl Widget for Confirm {
117    fn id(&self) -> &str {
118        &self.id
119    }
120
121    fn open(&mut self, ui: &mut UiContext<'_>) {
122        ui.play(UiSound::Open, None);
123        let answer = self.current(ui);
124        ui.speech.speak(format!("{} {answer}", self.question));
125    }
126
127    fn update(&mut self, ui: &mut UiContext<'_>) -> Status {
128        if ui.key(Key::Up) || ui.key(Key::Down) || ui.key(Key::Left) || ui.key(Key::Right) || ui.key(Key::Tab) {
129            self.selected_yes = !self.selected_yes;
130            ui.play(UiSound::Navigate, None);
131            let text = self.current(ui);
132            ui.speech.speak(text);
133        } else if ui.key(Key::Enter) || ui.key(Key::NumpadEnter) || ui.input.pressed(Key::Space) {
134            return self.answer(self.selected_yes, ui);
135        } else if ui.key(Key::Escape) {
136            return self.answer(false, ui);
137        } else if let Some(c) = ui.input.text().chars().next().and_then(|c| c.to_lowercase().next()) {
138            if Some(c) == first_lower(&self.label(true, ui)) {
139                return self.answer(true, ui);
140            }
141            if Some(c) == first_lower(&self.label(false, ui)) {
142                return self.answer(false, ui);
143            }
144        }
145        Status::Open
146    }
147}
148
149/// A single line text field.
150///
151/// | Key | Action |
152/// |-----|--------|
153/// | typing | inserts and speaks the character |
154/// | Left / Right | moves the cursor and speaks the character |
155/// | Control + Left / Right | moves by word |
156/// | Home / End | start / end |
157/// | Backspace / Delete | deletes and speaks the character |
158/// | Up / Down | speaks the whole text |
159/// | Enter | submits ([`UiEvent::TextSubmitted`]) |
160/// | Escape | cancels ([`UiEvent::Closed`]) |
161#[derive(Debug, Clone)]
162pub struct TextInput {
163    id: String,
164    prompt: String,
165    text: Vec<char>,
166    cursor: usize,
167    password: bool,
168    max_length: Option<usize>,
169    numeric: bool,
170    allow_empty: bool,
171    child: bool,
172}
173
174impl TextInput {
175    /// Creates an empty text field with a spoken prompt.
176    pub fn new(id: impl Into<String>, prompt: impl Into<String>) -> Self {
177        Self {
178            id: id.into(),
179            prompt: prompt.into(),
180            text: Vec::new(),
181            cursor: 0,
182            password: false,
183            max_length: None,
184            numeric: false,
185            allow_empty: true,
186            child: false,
187        }
188    }
189
190    /// Pre-fills the field; the cursor is placed at the end.
191    pub fn initial(mut self, text: impl Into<String>) -> Self {
192        self.text = text.into().chars().collect();
193        self.cursor = self.text.len();
194        self
195    }
196
197    /// Speaks "star" instead of the typed characters.
198    pub fn password(mut self) -> Self {
199        self.password = true;
200        self
201    }
202
203    /// Limits the number of characters.
204    pub fn max_length(mut self, max: usize) -> Self {
205        self.max_length = Some(max);
206        self
207    }
208
209    /// Accepts only digits, minus and decimal point.
210    pub fn numeric(mut self) -> Self {
211        self.numeric = true;
212        self
213    }
214
215    /// Refuses to submit an empty text (default: empty is allowed).
216    pub fn required(mut self) -> Self {
217        self.allow_empty = false;
218        self
219    }
220
221    /// Hands the result to the widget below instead of emitting events (used by menus).
222    pub(crate) fn child(mut self) -> Self {
223        self.child = true;
224        self
225    }
226
227    fn content(&self) -> String {
228        self.text.iter().collect()
229    }
230
231    fn speak_char(&self, c: Option<char>, ui: &mut UiContext<'_>) {
232        let text = match c {
233            None => ui.lang.get("ui.blank"),
234            Some(_) if self.password => ui.lang.get("ui.star"),
235            Some(c) => char_name(c, ui),
236        };
237        ui.speech.speak(text);
238    }
239
240    fn speak_all(&self, ui: &mut UiContext<'_>) {
241        let text = if self.text.is_empty() {
242            ui.lang.get("ui.blank")
243        } else if self.password {
244            ui.lang.plural("ui.characters", self.text.len() as i64)
245        } else {
246            self.content()
247        };
248        ui.speech.speak(text);
249    }
250
251    fn word_left(&self) -> usize {
252        let mut i = self.cursor;
253        while i > 0 && self.text[i - 1].is_whitespace() {
254            i -= 1;
255        }
256        while i > 0 && !self.text[i - 1].is_whitespace() {
257            i -= 1;
258        }
259        i
260    }
261
262    fn word_right(&self) -> usize {
263        let mut i = self.cursor;
264        while i < self.text.len() && !self.text[i].is_whitespace() {
265            i += 1;
266        }
267        while i < self.text.len() && self.text[i].is_whitespace() {
268            i += 1;
269        }
270        i
271    }
272}
273
274/// Spoken name of a character, so punctuation is not silently skipped.
275fn char_name(c: char, ui: &UiContext<'_>) -> String {
276    let key = match c {
277        ' ' => "char.space",
278        '.' => "char.dot",
279        ',' => "char.comma",
280        '!' => "char.exclamation",
281        '?' => "char.question",
282        '-' => "char.dash",
283        '_' => "char.underscore",
284        ':' => "char.colon",
285        ';' => "char.semicolon",
286        '\'' => "char.apostrophe",
287        '"' => "char.quote",
288        '/' => "char.slash",
289        '\\' => "char.backslash",
290        '@' => "char.at",
291        '#' => "char.number",
292        '(' => "char.left_paren",
293        ')' => "char.right_paren",
294        _ => return c.to_string(),
295    };
296    ui.lang.get(key)
297}
298
299impl Widget for TextInput {
300    fn id(&self) -> &str {
301        &self.id
302    }
303
304    fn open(&mut self, ui: &mut UiContext<'_>) {
305        ui.play(UiSound::Open, None);
306        let content = if self.text.is_empty() {
307            ui.lang.get("ui.blank")
308        } else if self.password {
309            ui.lang.plural("ui.characters", self.text.len() as i64)
310        } else {
311            self.content()
312        };
313        let text = ui.lang.format("ui.edit", &[("label", &self.prompt), ("value", &content)]);
314        ui.speech.speak(text);
315    }
316
317    fn update(&mut self, ui: &mut UiContext<'_>) -> Status {
318        let word = ui.input.modifiers().control;
319        if ui.key(Key::Enter) || ui.key(Key::NumpadEnter) {
320            if self.text.is_empty() && !self.allow_empty {
321                ui.play(UiSound::Edge, None);
322                let blank = ui.lang.get("ui.blank");
323                ui.speech.speak(blank);
324                return Status::Open;
325            }
326            ui.play(UiSound::Activate, None);
327            let text = self.content();
328            if self.child {
329                return Status::CloseWith(Value::Text(text));
330            }
331            ui.emit(UiEvent::TextSubmitted { id: self.id.clone(), text });
332            return Status::Close;
333        }
334        if ui.key(Key::Escape) {
335            ui.play(UiSound::Close, None);
336            if !self.child {
337                ui.emit(UiEvent::Closed { id: self.id.clone() });
338            }
339            return Status::Close;
340        }
341        if ui.key(Key::Left) {
342            if self.cursor == 0 {
343                ui.play(UiSound::Edge, None);
344            } else {
345                self.cursor = if word { self.word_left() } else { self.cursor - 1 };
346            }
347            self.speak_char(self.text.get(self.cursor).copied(), ui);
348        } else if ui.key(Key::Right) {
349            if self.cursor >= self.text.len() {
350                ui.play(UiSound::Edge, None);
351            } else {
352                self.cursor = if word { self.word_right() } else { self.cursor + 1 };
353            }
354            self.speak_char(self.text.get(self.cursor).copied(), ui);
355        } else if ui.key(Key::Home) {
356            self.cursor = 0;
357            self.speak_char(self.text.first().copied(), ui);
358        } else if ui.key(Key::End) {
359            self.cursor = self.text.len();
360            self.speak_char(None, ui);
361        } else if ui.key(Key::Up) || ui.key(Key::Down) {
362            self.speak_all(ui);
363        } else if ui.key(Key::Backspace) {
364            if self.cursor == 0 {
365                ui.play(UiSound::Edge, None);
366            } else {
367                self.cursor -= 1;
368                let removed = self.text.remove(self.cursor);
369                ui.play(UiSound::Typing, None);
370                self.speak_char(Some(removed), ui);
371            }
372        } else if ui.key(Key::Delete) {
373            if self.cursor < self.text.len() {
374                self.text.remove(self.cursor);
375                ui.play(UiSound::Typing, None);
376                self.speak_char(self.text.get(self.cursor).copied(), ui);
377            } else {
378                ui.play(UiSound::Edge, None);
379            }
380        }
381
382        let typed: Vec<char> = ui.input.text().chars().collect();
383        let mut last = None;
384        for c in typed {
385            if self.numeric && !(c.is_ascii_digit() || c == '-' || c == '.' || c == ',') {
386                continue;
387            }
388            if self.max_length.is_some_and(|m| self.text.len() >= m) {
389                ui.play(UiSound::Edge, None);
390                break;
391            }
392            self.text.insert(self.cursor, c);
393            self.cursor += 1;
394            last = Some(c);
395        }
396        if last.is_some() {
397            ui.play(UiSound::Typing, None);
398            self.speak_char(last, ui);
399        }
400        Status::Open
401    }
402
403    fn value(&self, _item: &str) -> Option<Value> {
404        Some(Value::Text(self.content()))
405    }
406}