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