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