Skip to main content

audio_code/ui/
menu.rs

1//! Audio menus.
2
3use super::dialog::TextInput;
4use super::{Status, UiContext, UiEvent, UiSound, UiSounds, Value, Widget, format_number};
5use crate::lang::Lang;
6use crate::input::Key;
7
8/// What kind of item a [`MenuItem`] is.
9#[derive(Debug, Clone, PartialEq)]
10pub enum ItemKind {
11    /// Pressing Enter emits [`UiEvent::Activated`].
12    Button,
13    /// On/off toggle (Enter, Space, Left or Right).
14    Checkbox(bool),
15    /// Number adjusted with Left/Right (one step) and Page Up/Down (ten steps).
16    Slider {
17        /// Current value.
18        value: f32,
19        /// Lowest value.
20        min: f32,
21        /// Highest value.
22        max: f32,
23        /// Change per key press.
24        step: f32,
25    },
26    /// One of several options, cycled with Left/Right or Enter.
27    Choice {
28        /// Option texts.
29        options: Vec<String>,
30        /// Selected index.
31        selected: usize,
32    },
33    /// Editable text; Enter opens a text input.
34    Text {
35        /// Current text.
36        value: String,
37        /// Speak stars instead of the content.
38        password: bool,
39    },
40    /// Information only, cannot be activated.
41    Label,
42}
43
44/// One entry of a [`Menu`].
45#[derive(Debug, Clone, PartialEq)]
46pub struct MenuItem {
47    /// Identifier used in events.
48    pub id: String,
49    /// Spoken label.
50    pub label: String,
51    /// Kind and value.
52    pub kind: ItemKind,
53}
54
55impl MenuItem {
56    /// The current value, `None` for buttons and labels.
57    pub fn value(&self) -> Option<Value> {
58        match &self.kind {
59            ItemKind::Checkbox(b) => Some(Value::Bool(*b)),
60            ItemKind::Slider { value, .. } => Some(Value::Number(*value)),
61            ItemKind::Choice { options, selected } => {
62                Some(Value::Choice(*selected, options.get(*selected).cloned().unwrap_or_default()))
63            }
64            ItemKind::Text { value, .. } => Some(Value::Text(value.clone())),
65            ItemKind::Button | ItemKind::Label => None,
66        }
67    }
68
69    /// What a screen reader would say for this item, in English.
70    pub fn describe(&self) -> String {
71        self.describe_in(&Lang::new())
72    }
73
74    /// What a screen reader would say for this item, in the current language of `lang`.
75    pub fn describe_in(&self, lang: &Lang) -> String {
76        match &self.kind {
77            ItemKind::Button | ItemKind::Label => self.label.clone(),
78            ItemKind::Checkbox(b) => format!("{}, {}", self.label, lang.get(if *b { "ui.checked" } else { "ui.not_checked" })),
79            ItemKind::Slider { value, .. } => format!("{}, {}", self.label, format_number(*value)),
80            ItemKind::Choice { options, selected } => {
81                format!("{}, {}", self.label, options.get(*selected).map_or("", String::as_str))
82            }
83            ItemKind::Text { value, password } => {
84                let shown = if value.is_empty() {
85                    lang.get("ui.blank")
86                } else if *password {
87                    lang.plural("ui.characters", value.chars().count() as i64)
88                } else {
89                    value.clone()
90                };
91                lang.format("ui.edit", &[("label", &self.label), ("value", &shown)])
92            }
93        }
94    }
95}
96
97/// A vertical list of items navigated with the arrow keys.
98///
99/// | Key | Action |
100/// |-----|--------|
101/// | Up / Down | previous / next item |
102/// | Home / End | first / last item |
103/// | Enter / Space | press button, toggle checkbox, next choice, edit text |
104/// | Left / Right | adjust slider or choice, toggle checkbox |
105/// | Page Up / Page Down | adjust slider by ten steps |
106/// | Letters | jump to the next item starting with that letter |
107/// | Escape | close (if [`Menu::closable`]) |
108///
109/// ```no_run
110/// # use audio_code::prelude::*;
111/// let menu = Menu::new("options", "Options")
112///     .slider("volume", "Volume", 80.0, 0.0, 100.0, 5.0)
113///     .choice("difficulty", "Difficulty", &["Easy", "Normal", "Hard"], 1)
114///     .checkbox("subtitles", "Speak hints", true)
115///     .text_field("name", "Player name", "Hero")
116///     .button("back", "Back")
117///     .music("menu_theme");
118/// ```
119#[derive(Debug, Clone)]
120pub struct Menu {
121    id: String,
122    title: String,
123    items: Vec<MenuItem>,
124    index: usize,
125    wrap: bool,
126    closable: bool,
127    positions: bool,
128    close_on_activate: bool,
129    sounds: Option<UiSounds>,
130    sound_overrides: Vec<(UiSound, Option<String>)>,
131    music: Option<String>,
132    editing: Option<usize>,
133}
134
135impl Menu {
136    /// Creates an empty menu. `title` is spoken when it opens (may be empty).
137    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
138        Self {
139            id: id.into(),
140            title: title.into(),
141            items: Vec::new(),
142            index: 0,
143            wrap: false,
144            closable: true,
145            positions: false,
146            close_on_activate: false,
147            sounds: None,
148            sound_overrides: Vec::new(),
149            music: None,
150            editing: None,
151        }
152    }
153
154    /// Adds any item.
155    pub fn item(mut self, item: MenuItem) -> Self {
156        self.items.push(item);
157        self
158    }
159
160    fn push(self, id: impl Into<String>, label: impl Into<String>, kind: ItemKind) -> Self {
161        self.item(MenuItem { id: id.into(), label: label.into(), kind })
162    }
163
164    /// Adds a button.
165    pub fn button(self, id: impl Into<String>, label: impl Into<String>) -> Self {
166        self.push(id, label, ItemKind::Button)
167    }
168
169    /// Adds a checkbox.
170    pub fn checkbox(self, id: impl Into<String>, label: impl Into<String>, checked: bool) -> Self {
171        self.push(id, label, ItemKind::Checkbox(checked))
172    }
173
174    /// Adds a slider. `value` is the start value, `min` and `max` the range, and `step`
175    /// how much Left and Right change it (Page Up and Page Down change it by ten steps).
176    /// `slider("volume", "Volume", 80.0, 0.0, 100.0, 5.0)` starts at 80 and goes from 0 to
177    /// 100 in steps of 5. Read the value with `Value::as_f32`.
178    pub fn slider(self, id: impl Into<String>, label: impl Into<String>, value: f32, min: f32, max: f32, step: f32) -> Self {
179        let value = value.clamp(min, max);
180        self.push(id, label, ItemKind::Slider { value, min, max, step: step.abs().max(f32::EPSILON) })
181    }
182
183    /// Adds a choice between several options. `selected` is the index of the option chosen
184    /// at the start (0 = the first one).
185    pub fn choice(self, id: impl Into<String>, label: impl Into<String>, options: &[&str], selected: usize) -> Self {
186        let options: Vec<String> = options.iter().map(|s| s.to_string()).collect();
187        let selected = selected.min(options.len().saturating_sub(1));
188        self.push(id, label, ItemKind::Choice { options, selected })
189    }
190
191    /// Adds an editable text field.
192    pub fn text_field(self, id: impl Into<String>, label: impl Into<String>, value: impl Into<String>) -> Self {
193        self.push(id, label, ItemKind::Text { value: value.into(), password: false })
194    }
195
196    /// Adds a password field (content is never spoken).
197    pub fn password_field(self, id: impl Into<String>, label: impl Into<String>) -> Self {
198        self.push(id, label, ItemKind::Text { value: String::new(), password: true })
199    }
200
201    /// Adds a line of information that cannot be activated.
202    pub fn label(self, text: impl Into<String>) -> Self {
203        let text = text.into();
204        self.push("", text, ItemKind::Label)
205    }
206
207    /// Jump from the last to the first item and back (default `false`).
208    pub fn wrap(mut self, wrap: bool) -> Self {
209        self.wrap = wrap;
210        self
211    }
212
213    /// Allow closing with Escape (default `true`). A main menu often sets this to `false`.
214    pub fn closable(mut self, closable: bool) -> Self {
215        self.closable = closable;
216        self
217    }
218
219    /// Speak "2 of 5" after each item (default `false`).
220    pub fn positions(mut self, positions: bool) -> Self {
221        self.positions = positions;
222        self
223    }
224
225    /// Close the menu automatically after a button was pressed (default `false`).
226    pub fn close_on_activate(mut self, close: bool) -> Self {
227        self.close_on_activate = close;
228        self
229    }
230
231    /// Use these sounds instead of the global ones for this menu.
232    pub fn sounds(mut self, sounds: UiSounds) -> Self {
233        self.sounds = Some(sounds);
234        self
235    }
236
237    /// Changes one sound for this menu only, e.g. a special click for the main menu.
238    /// `None` makes it silent. All other sounds stay as they are.
239    pub fn sound(mut self, kind: UiSound, name: Option<&str>) -> Self {
240        self.sound_overrides.push((kind, name.map(String::from)));
241        self
242    }
243
244    /// Plays the music `name` in a loop while this menu is open (a file or registered sound).
245    /// Submenus and dialogs on top keep it playing unless they have music of their own.
246    /// When the menu closes, the music that played before comes back, or the music fades out.
247    pub fn music(mut self, name: impl Into<String>) -> Self {
248        self.music = Some(name.into());
249        self
250    }
251
252    fn play(&self, ui: &mut UiContext<'_>, kind: UiSound) {
253        match self.sound_overrides.iter().rev().find(|(k, _)| *k == kind) {
254            Some((_, Some(name))) => ui.play_named(name),
255            Some((_, None)) => {}
256            None => ui.play(kind, self.sounds.as_ref()),
257        }
258    }
259
260    /// Start with the item `id` focused.
261    pub fn focus(mut self, id: &str) -> Self {
262        if let Some(i) = self.items.iter().position(|it| it.id == id) {
263            self.index = i;
264        }
265        self
266    }
267
268    /// The items of this menu.
269    pub fn items(&self) -> &[MenuItem] {
270        &self.items
271    }
272
273    /// The focused item.
274    pub fn current(&self) -> Option<&MenuItem> {
275        self.items.get(self.index)
276    }
277
278    fn announce(&self, ui: &mut UiContext<'_>, with_title: bool) {
279        let mut text = String::new();
280        if with_title && !self.title.is_empty() {
281            text.push_str(&self.title);
282            text.push_str(". ");
283        }
284        match self.current() {
285            Some(item) => {
286                text.push_str(&item.describe_in(ui.lang));
287                if self.positions {
288                    let (index, count) = (self.index + 1, self.items.len());
289                    text.push_str(", ");
290                    text.push_str(&ui.lang.format("ui.position", &[("index", &index), ("count", &count)]));
291                }
292            }
293            None => text.push_str(&ui.lang.get("ui.empty")),
294        }
295        ui.speech.speak(text);
296    }
297
298    fn speak_value(&self, ui: &mut UiContext<'_>) {
299        if let Some(item) = self.current() {
300            match item.value() {
301                Some(Value::Bool(b)) => ui.speech.speak(ui.lang.get(if b { "ui.checked" } else { "ui.not_checked" })),
302                Some(v) => ui.speech.speak(v.to_text()),
303                None => {}
304            }
305        }
306    }
307
308    fn move_to(&mut self, index: usize, ui: &mut UiContext<'_>) {
309        self.move_with_sound(index, UiSound::Navigate, ui);
310    }
311
312    fn move_with_sound(&mut self, index: usize, sound: UiSound, ui: &mut UiContext<'_>) {
313        if index == self.index {
314            self.play(ui, UiSound::Edge);
315        } else {
316            self.play(ui, sound);
317            self.index = index;
318        }
319        self.announce(ui, false);
320    }
321
322    fn changed(&mut self, ui: &mut UiContext<'_>) {
323        self.play(ui, UiSound::Change);
324        self.speak_value(ui);
325        if let Some(item) = self.current()
326            && let Some(value) = item.value()
327        {
328            let event = UiEvent::Changed { menu: self.id.clone(), item: item.id.clone(), value };
329            ui.emit(event);
330        }
331    }
332
333    /// Adjusts the focused item by `steps` (negative = left). Returns whether anything changed.
334    fn adjust(&mut self, steps: i32) -> bool {
335        let Some(item) = self.items.get_mut(self.index) else {
336            return false;
337        };
338        match &mut item.kind {
339            ItemKind::Checkbox(b) => {
340                *b = !*b;
341                true
342            }
343            ItemKind::Slider { value, min, max, step } => {
344                let new = (*value + *step * steps as f32).clamp(*min, *max);
345                // Snap to the step grid to avoid 0.30000000004.
346                let new = ((new - *min) / *step).round() * *step + *min;
347                let new = new.clamp(*min, *max);
348                let changed = new != *value;
349                *value = new;
350                changed
351            }
352            ItemKind::Choice { options, selected } => {
353                if options.is_empty() {
354                    return false;
355                }
356                let n = options.len() as i32;
357                let new = (*selected as i32 + steps).rem_euclid(n) as usize;
358                let changed = new != *selected;
359                *selected = new;
360                changed
361            }
362            _ => false,
363        }
364    }
365
366    fn jump_to_letter(&mut self, c: char, ui: &mut UiContext<'_>) {
367        let n = self.items.len();
368        let c = c.to_lowercase().next().unwrap_or(c);
369        let found = (1..=n).map(|off| (self.index + off) % n).find(|&i| {
370            self.items[i].label.chars().next().and_then(|f| f.to_lowercase().next()) == Some(c)
371        });
372        match found {
373            Some(i) if i != self.index => {
374                self.index = i;
375                self.play(ui, UiSound::Navigate);
376                self.announce(ui, false);
377            }
378            Some(_) => self.announce(ui, false),
379            None => self.play(ui, UiSound::Edge),
380        }
381    }
382}
383
384impl Widget for Menu {
385    fn id(&self) -> &str {
386        &self.id
387    }
388
389    fn open(&mut self, ui: &mut UiContext<'_>) {
390        self.index = self.index.min(self.items.len().saturating_sub(1));
391        self.play(ui, UiSound::Open);
392        self.announce(ui, true);
393    }
394
395    fn resume(&mut self, value: Option<Value>, ui: &mut UiContext<'_>) {
396        if let (Some(i), Some(Value::Text(text))) = (self.editing.take(), value)
397            && let Some(item) = self.items.get_mut(i)
398            && let ItemKind::Text { value, .. } = &mut item.kind
399        {
400            *value = text.clone();
401            let event = UiEvent::Changed { menu: self.id.clone(), item: item.id.clone(), value: Value::Text(text) };
402            ui.emit(event);
403        }
404        self.announce(ui, false);
405    }
406
407    fn update(&mut self, ui: &mut UiContext<'_>) -> Status {
408        if self.items.is_empty() {
409            if self.closable && ui.key(Key::Escape) {
410                ui.emit(UiEvent::Closed { id: self.id.clone() });
411                return Status::Close;
412            }
413            return Status::Open;
414        }
415        let last = self.items.len() - 1;
416
417        if ui.key(Key::Up) {
418            if self.index > 0 {
419                self.move_to(self.index - 1, ui);
420            } else if self.wrap && last > 0 {
421                self.move_with_sound(last, UiSound::Wrap, ui);
422            } else {
423                self.move_to(0, ui);
424            }
425        } else if ui.key(Key::Down) || ui.key(Key::Tab) {
426            if self.index < last {
427                self.move_to(self.index + 1, ui);
428            } else if self.wrap && last > 0 {
429                self.move_with_sound(0, UiSound::Wrap, ui);
430            } else {
431                self.move_to(last, ui);
432            }
433        } else if ui.key(Key::Home) {
434            self.move_to(0, ui);
435        } else if ui.key(Key::End) {
436            self.move_to(last, ui);
437        } else if ui.key(Key::Left) || ui.key(Key::Right) || ui.key(Key::PageUp) || ui.key(Key::PageDown) {
438            let steps: i32 = if ui.key(Key::Left) {
439                -1
440            } else if ui.key(Key::Right) {
441                1
442            } else if ui.key(Key::PageUp) {
443                10
444            } else {
445                -10
446            };
447            let is_slider = matches!(self.current().map(|i| &i.kind), Some(ItemKind::Slider { .. }));
448            let is_paging = steps.abs() == 10;
449            if is_paging && !is_slider {
450                let to = if steps > 0 { 0 } else { last };
451                self.move_to(to, ui);
452            } else if self.adjust(steps) {
453                self.changed(ui);
454            } else if is_slider || matches!(self.current().map(|i| &i.kind), Some(ItemKind::Choice { .. })) {
455                self.play(ui, UiSound::Edge);
456                self.speak_value(ui);
457            }
458        } else if ui.key(Key::Enter) || ui.key(Key::NumpadEnter) || ui.input.pressed(Key::Space) {
459            let item = &self.items[self.index];
460            match &item.kind {
461                ItemKind::Button => {
462                    self.play(ui, UiSound::Activate);
463                    ui.emit(UiEvent::Activated { menu: self.id.clone(), item: item.id.clone() });
464                    if self.close_on_activate {
465                        return Status::Close;
466                    }
467                }
468                ItemKind::Checkbox(_) | ItemKind::Choice { .. } => {
469                    if self.adjust(1) {
470                        self.changed(ui);
471                    }
472                }
473                ItemKind::Text { value, password } => {
474                    self.play(ui, UiSound::Activate);
475                    let mut input = TextInput::new(format!("{}/{}", self.id, item.id), item.label.clone())
476                        .initial(value.clone())
477                        .child();
478                    if *password {
479                        input = input.password();
480                    }
481                    self.editing = Some(self.index);
482                    return Status::Push(Box::new(input));
483                }
484                ItemKind::Slider { .. } | ItemKind::Label => self.announce(ui, false),
485            }
486        } else if ui.key(Key::Escape) && self.closable {
487            self.play(ui, UiSound::Close);
488            ui.emit(UiEvent::Closed { id: self.id.clone() });
489            return Status::Close;
490        } else if let Some(c) = ui.input.text().chars().find(|c| c.is_alphanumeric()) {
491            self.jump_to_letter(c, ui);
492        }
493        Status::Open
494    }
495
496    fn value(&self, item: &str) -> Option<Value> {
497        self.items.iter().find(|i| i.id == item).and_then(MenuItem::value)
498    }
499
500    fn music(&self) -> Option<&str> {
501        self.music.as_deref()
502    }
503}