Skip to main content

audio_code/ui/
mod.rs

1//! Virtual, speech based user interface. Available as `ctx.ui`.
2//!
3//! Nothing here is drawn on screen. Every widget announces itself through
4//! [`Speech`] and plays short interface sounds, and reacts to the
5//! keyboard directly. While any widget is open it owns the keyboard: game input is blocked
6//! (see [`Input::is_blocked`](crate::input::Input::is_blocked)).
7//!
8//! Widgets form a stack. The top one receives input; closing it returns to the one below,
9//! which re-announces itself. This makes sub menus trivial.
10//!
11//! ```no_run
12//! # use audio_code::prelude::*;
13//! # fn f(ctx: &mut Context) {
14//! ctx.ui.open(
15//!     Menu::new("main", "Main menu")
16//!         .button("play", "Play")
17//!         .slider("volume", "Volume", 80.0, 0.0, 100.0, 10.0)
18//!         .checkbox("music", "Music", true)
19//!         .button("quit", "Quit"),
20//! );
21//! // ...later, in update():
22//! if ctx.ui.clicked("main", "play") {
23//!     ctx.ui.close();
24//! }
25//! if let Some(volume) = ctx.ui.changed("main", "volume") {
26//!     ctx.audio.set_master_volume(volume.as_f32() / 100.0);
27//! }
28//! # }
29//! ```
30//!
31//! Built in widgets: [`Menu`], [`Message`], [`Confirm`] and [`TextInput`]. You can write your
32//! own by implementing [`Widget`].
33
34mod dialog;
35mod menu;
36mod sounds;
37
38pub use dialog::{Confirm, Message, TextInput};
39pub use menu::{ItemKind, Menu, MenuItem};
40pub use sounds::{UiSound, UiSounds};
41
42use crate::audio::{Audio, SoundData};
43use crate::input::{Input, Key, RawInput};
44use crate::speech::Speech;
45
46/// A value carried by a UI event or stored in a menu item.
47#[derive(Debug, Clone, PartialEq)]
48pub enum Value {
49    /// Checkbox state.
50    Bool(bool),
51    /// Slider value.
52    Number(f32),
53    /// Selected option of a choice: index and text.
54    Choice(usize, String),
55    /// Text field contents.
56    Text(String),
57}
58
59impl Value {
60    /// The value as `bool`. Numbers are `true` when not zero, text when not empty.
61    pub fn as_bool(&self) -> bool {
62        match self {
63            Value::Bool(b) => *b,
64            Value::Number(n) => *n != 0.0,
65            Value::Choice(i, _) => *i != 0,
66            Value::Text(t) => !t.is_empty(),
67        }
68    }
69
70    /// The value as number (choices give their index, text is parsed, `bool` gives 0 or 1).
71    pub fn as_f32(&self) -> f32 {
72        match self {
73            Value::Bool(b) => *b as u8 as f32,
74            Value::Number(n) => *n,
75            Value::Choice(i, _) => *i as f32,
76            Value::Text(t) => t.trim().parse().unwrap_or(0.0),
77        }
78    }
79
80    /// The value as index (for choices) or rounded number.
81    pub fn as_index(&self) -> usize {
82        self.as_f32().max(0.0).round() as usize
83    }
84
85    /// The value as text.
86    pub fn as_str(&self) -> &str {
87        match self {
88            Value::Bool(true) => "on",
89            Value::Bool(false) => "off",
90            Value::Number(_) => "",
91            Value::Choice(_, s) | Value::Text(s) => s,
92        }
93    }
94
95    /// The value formatted for speaking.
96    pub fn to_text(&self) -> String {
97        match self {
98            Value::Number(n) => format_number(*n),
99            other => other.as_str().to_string(),
100        }
101    }
102}
103
104pub(crate) fn format_number(n: f32) -> String {
105    if (n - n.round()).abs() < 1e-4 { format!("{}", n.round() as i64) } else { format!("{n:.2}") }
106}
107
108/// Something that happened in the UI this frame.
109#[derive(Debug, Clone, PartialEq)]
110pub enum UiEvent {
111    /// A menu button was pressed.
112    Activated {
113        /// Menu id.
114        menu: String,
115        /// Item id.
116        item: String,
117    },
118    /// A checkbox, slider, choice or text field in a menu changed.
119    Changed {
120        /// Menu id.
121        menu: String,
122        /// Item id.
123        item: String,
124        /// New value.
125        value: Value,
126    },
127    /// A widget was closed by the player (Escape, or Enter on a message).
128    Closed {
129        /// Widget id.
130        id: String,
131    },
132    /// The player answered a [`Confirm`] dialog.
133    Confirmed {
134        /// Widget id.
135        id: String,
136        /// `true` for yes.
137        yes: bool,
138    },
139    /// The player pressed Enter in a [`TextInput`].
140    TextSubmitted {
141        /// Widget id.
142        id: String,
143        /// The entered text.
144        text: String,
145    },
146}
147
148/// What a widget wants to happen after its update.
149#[derive(Default)]
150pub enum Status {
151    /// Stay open.
152    #[default]
153    Open,
154    /// Close this widget.
155    Close,
156    /// Close this widget and hand a value to the widget below
157    /// (it receives it in [`Widget::resume`]).
158    CloseWith(Value),
159    /// Open another widget on top of this one.
160    Push(Box<dyn Widget>),
161}
162
163/// A virtual UI element. Implement this to create your own widgets.
164pub trait Widget: 'static {
165    /// Identifier used in events.
166    fn id(&self) -> &str;
167
168    /// Called once when the widget becomes active. Announce yourself here.
169    fn open(&mut self, ui: &mut UiContext<'_>);
170
171    /// Called every frame while this widget is on top of the stack.
172    fn update(&mut self, ui: &mut UiContext<'_>) -> Status;
173
174    /// Called when the widget becomes the top again after the one above closed.
175    /// `value` is what the closed widget passed with [`Status::CloseWith`].
176    fn resume(&mut self, value: Option<Value>, ui: &mut UiContext<'_>) {
177        let _ = value;
178        self.open(ui);
179    }
180
181    /// Current value of an item, used by [`Ui::value`].
182    fn value(&self, item: &str) -> Option<Value> {
183        let _ = item;
184        None
185    }
186
187    /// Music that plays while this widget is open, see [`Menu::music`].
188    /// Widgets above it without music of their own keep it playing.
189    fn music(&self) -> Option<&str> {
190        None
191    }
192}
193
194/// Everything a widget may use while it updates.
195pub struct UiContext<'a> {
196    /// Keyboard state (never blocked for widgets).
197    pub input: RawInput<'a>,
198    /// Speech output.
199    pub speech: &'a mut Speech,
200    /// Audio output.
201    pub audio: &'a mut Audio,
202    sounds: &'a UiSounds,
203    events: &'a mut Vec<UiEvent>,
204}
205
206impl UiContext<'_> {
207    /// Reports an event to the game.
208    pub fn emit(&mut self, event: UiEvent) {
209        self.events.push(event);
210    }
211
212    /// Plays one of the interface sounds, using `custom` if given, else the global set.
213    pub fn play(&mut self, sound: UiSound, custom: Option<&UiSounds>) {
214        let set = custom.unwrap_or(self.sounds);
215        if let Some(name) = set.get(sound) {
216            let name = name.to_string();
217            self.play_named(&name);
218        }
219    }
220
221    /// Plays a sound by name in the UI volume group.
222    pub fn play_named(&mut self, name: &str) {
223        sounds::ensure_builtin(self.audio, name);
224        self.audio.sound(name).group(crate::audio::Group::Ui).play();
225    }
226
227    /// Shortcut: key went down or repeats.
228    pub fn key(&self, key: Key) -> bool {
229        self.input.pressed_or_repeated(key)
230    }
231}
232
233enum State {
234    Fresh,
235    Active,
236    Resume(Option<Value>),
237}
238
239struct Entry {
240    widget: Box<dyn Widget>,
241    state: State,
242}
243
244/// The widget stack. Reach it through `ctx.ui`.
245#[derive(Default)]
246pub struct Ui {
247    stack: Vec<Entry>,
248    events: Vec<UiEvent>,
249    sounds: UiSounds,
250    /// Sounds made with `set_sound_data`, registered with the audio system on the next update.
251    pending_sounds: Vec<(String, SoundData)>,
252    /// Widget music that the UI started and that is playing now.
253    music: Option<String>,
254    /// Music that played before the widget music, restored when no widget wants music.
255    previous_music: Option<String>,
256}
257
258impl std::fmt::Debug for Ui {
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        let ids: Vec<&str> = self.stack.iter().map(|e| e.widget.id()).collect();
261        f.debug_struct("Ui").field("stack", &ids).field("events", &self.events).finish()
262    }
263}
264
265
266
267impl Ui {
268    /// Runs the top widget. Returns `true` if the UI used the keyboard this frame.
269    pub(crate) fn update(&mut self, input: &Input, speech: &mut Speech, audio: &mut Audio) -> bool {
270        self.events.clear();
271        for (name, data) in self.pending_sounds.drain(..) {
272            audio.register(name, data);
273        }
274        self.update_music(audio);
275        let Some(top) = self.stack.last_mut() else {
276            return false;
277        };
278        let mut ui = UiContext { input: input.raw(), speech, audio, sounds: &self.sounds, events: &mut self.events };
279        let status = match std::mem::replace(&mut top.state, State::Active) {
280            State::Fresh => {
281                top.widget.open(&mut ui);
282                Status::Open
283            }
284            State::Resume(value) => {
285                top.widget.resume(value, &mut ui);
286                Status::Open
287            }
288            State::Active => top.widget.update(&mut ui),
289        };
290        match status {
291            Status::Open => {}
292            Status::Close => self.pop(None),
293            Status::CloseWith(value) => self.pop(Some(value)),
294            Status::Push(widget) => self.stack.push(Entry { widget, state: State::Fresh }),
295        }
296        true
297    }
298
299    /// Plays the music of the topmost widget that has some. When no widget wants music any
300    /// more, the music from before comes back, unless the game started other music meanwhile.
301    fn update_music(&mut self, audio: &mut Audio) {
302        let wanted = self.stack.iter().rev().find_map(|e| e.widget.music());
303        if wanted == self.music.as_deref() {
304            return;
305        }
306        match wanted {
307            Some(name) => {
308                if self.music.is_none() {
309                    self.previous_music = audio.music_name().map(String::from);
310                }
311                audio.play_music(name);
312                self.music = Some(name.to_string());
313            }
314            None => {
315                let ours = audio.music_name().is_some() && audio.music_name() == self.music.as_deref();
316                let previous = self.previous_music.take();
317                self.music = None;
318                if ours {
319                    match previous {
320                        Some(name) => {
321                            audio.play_music(&name);
322                        }
323                        None => audio.stop_music(1.0),
324                    }
325                }
326            }
327        }
328    }
329
330    fn pop(&mut self, value: Option<Value>) {
331        self.stack.pop();
332        if let Some(below) = self.stack.last_mut() {
333            below.state = State::Resume(value);
334        }
335    }
336
337    /// Opens a widget on top of the stack. It announces itself on the next frame.
338    pub fn open(&mut self, widget: impl Widget) {
339        self.stack.push(Entry { widget: Box::new(widget), state: State::Fresh });
340    }
341
342    /// Opens a boxed widget.
343    pub fn open_boxed(&mut self, widget: Box<dyn Widget>) {
344        self.stack.push(Entry { widget, state: State::Fresh });
345    }
346
347    /// Shows a message. Enter, Space or Escape closes it and emits [`UiEvent::Closed`].
348    /// Multi line text can be reviewed line by line with the arrow keys.
349    pub fn message(&mut self, id: impl Into<String>, text: impl Into<String>) {
350        self.open(Message::new(id, text));
351    }
352
353    /// Asks a yes/no question. The answer arrives as [`UiEvent::Confirmed`]; see [`Ui::confirmed`].
354    pub fn confirm(&mut self, id: impl Into<String>, question: impl Into<String>) {
355        self.open(Confirm::new(id, question));
356    }
357
358    /// Asks the player to type text. The result arrives as [`UiEvent::TextSubmitted`];
359    /// see [`Ui::submitted`].
360    pub fn input(&mut self, id: impl Into<String>, prompt: impl Into<String>) {
361        self.open(TextInput::new(id, prompt));
362    }
363
364    /// Closes the top widget without any sound or event.
365    pub fn close(&mut self) {
366        self.pop(None);
367    }
368
369    /// Closes the widget with the given id and everything above it.
370    pub fn close_id(&mut self, id: &str) {
371        if let Some(i) = self.stack.iter().position(|e| e.widget.id() == id) {
372            self.stack.truncate(i + 1);
373            self.pop(None);
374        }
375    }
376
377    /// Closes every widget.
378    pub fn close_all(&mut self) {
379        self.stack.clear();
380    }
381
382    /// Whether any widget is open.
383    pub fn is_open(&self) -> bool {
384        !self.stack.is_empty()
385    }
386
387    /// Whether a widget with this id is anywhere on the stack.
388    pub fn contains(&self, id: &str) -> bool {
389        self.stack.iter().any(|e| e.widget.id() == id)
390    }
391
392    /// Id of the widget that currently has the keyboard.
393    pub fn top(&self) -> Option<&str> {
394        self.stack.last().map(|e| e.widget.id())
395    }
396
397    /// Replaces the interface sounds used by all widgets. Use [`UiSounds::none`] for silence.
398    pub fn set_sounds(&mut self, sounds: UiSounds) {
399        self.sounds = sounds;
400    }
401
402    /// The interface sounds used by all widgets.
403    pub fn sounds(&self) -> &UiSounds {
404        &self.sounds
405    }
406
407    /// Uses the sound `name` (a file or registered sound) for one kind of interface sound.
408    /// `None` makes it silent.
409    ///
410    /// ```no_run
411    /// # use audio_code::prelude::*;
412    /// # fn f(ctx: &mut Context) {
413    /// ctx.ui.set_sound(UiSound::Navigate, Some("ui/click"));   // sounds/ui/click.ogg
414    /// ctx.ui.set_sound(UiSound::Edge, None);
415    /// # }
416    /// ```
417    pub fn set_sound(&mut self, kind: UiSound, name: Option<&str>) {
418        self.sounds.set(kind, name.map(String::from));
419    }
420
421    /// Uses a sound made in code for one kind of interface sound, typically a [`Synth`]:
422    ///
423    /// ```no_run
424    /// # use audio_code::prelude::*;
425    /// # fn f(ctx: &mut Context) {
426    /// ctx.ui.set_sound_data(UiSound::Navigate, Synth::triangle(1800.0).length(0.02).decay(150.0));
427    /// ctx.ui.set_sound_data(UiSound::Typing, Synth::noise().length(0.01).volume(0.2));
428    /// # }
429    /// ```
430    ///
431    /// [`Synth`]: crate::audio::Synth
432    pub fn set_sound_data(&mut self, kind: UiSound, data: impl Into<SoundData>) {
433        let name = format!("ui:custom:{}", kind.name());
434        self.pending_sounds.push((name.clone(), data.into()));
435        self.sounds.set(kind, Some(name));
436    }
437
438    /// Current value of a menu item while the menu is open.
439    pub fn value(&self, menu: &str, item: &str) -> Option<Value> {
440        self.stack.iter().rev().find(|e| e.widget.id() == menu).and_then(|e| e.widget.value(item))
441    }
442
443    // ----------------------------------------------------------------- events
444
445    /// All UI events of this frame.
446    pub fn events(&self) -> &[UiEvent] {
447        &self.events
448    }
449
450    pub(crate) fn take_events(&mut self) -> Vec<UiEvent> {
451        self.events.clone()
452    }
453
454    /// Id of the button activated in `menu` this frame.
455    pub fn activated(&self, menu: &str) -> Option<&str> {
456        self.events.iter().find_map(|e| match e {
457            UiEvent::Activated { menu: m, item } if m == menu => Some(item.as_str()),
458            _ => None,
459        })
460    }
461
462    /// Whether button `item` in `menu` was activated this frame.
463    pub fn clicked(&self, menu: &str, item: &str) -> bool {
464        self.activated(menu) == Some(item)
465    }
466
467    /// New value of `item` in `menu` if it changed this frame.
468    pub fn changed(&self, menu: &str, item: &str) -> Option<&Value> {
469        self.events.iter().find_map(|e| match e {
470            UiEvent::Changed { menu: m, item: i, value } if m == menu && i == item => Some(value),
471            _ => None,
472        })
473    }
474
475    /// Whether the widget `id` was closed by the player this frame.
476    pub fn closed(&self, id: &str) -> bool {
477        self.events.iter().any(|e| matches!(e, UiEvent::Closed { id: i } if i == id))
478    }
479
480    /// The answer to confirm dialog `id`, if given this frame.
481    pub fn confirmed(&self, id: &str) -> Option<bool> {
482        self.events.iter().find_map(|e| match e {
483            UiEvent::Confirmed { id: i, yes } if i == id => Some(*yes),
484            _ => None,
485        })
486    }
487
488    /// Text submitted in text input `id` this frame.
489    pub fn submitted(&self, id: &str) -> Option<&str> {
490        self.events.iter().find_map(|e| match e {
491            UiEvent::TextSubmitted { id: i, text } if i == id => Some(text.as_str()),
492            _ => None,
493        })
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::input::KeyEvent;
501    use std::time::Instant;
502
503    pub(crate) struct Harness {
504        pub ui: Ui,
505        pub input: Input,
506        pub speech: Speech,
507        pub audio: Audio,
508    }
509
510    impl Harness {
511        pub fn new() -> Self {
512            let mut speech = Speech::console();
513            speech.set_echo(false);
514            Self { ui: Ui::default(), input: Input::default(), speech, audio: Audio::silent("sounds") }
515        }
516
517        pub fn frame(&mut self, keys: &[Key], text: &str) {
518            for &key in keys {
519                self.input.push(KeyEvent::Down { key, repeat: false });
520                self.input.push(KeyEvent::Up { key });
521            }
522            self.input.push_text(text);
523            self.input.begin_frame(Instant::now());
524            self.ui.update(&self.input, &mut self.speech, &mut self.audio);
525        }
526    }
527
528    #[test]
529    fn menu_navigation_and_activation() {
530        let mut h = Harness::new();
531        h.ui.open(Menu::new("main", "Main menu").button("play", "Play").button("quit", "Quit"));
532        h.frame(&[], "");
533        assert_eq!(h.speech.last(), Some("Main menu. Play"));
534        h.frame(&[Key::Down], "");
535        assert_eq!(h.speech.last(), Some("Quit"));
536        h.frame(&[Key::Enter], "");
537        assert!(h.ui.clicked("main", "quit"));
538        h.frame(&[Key::Escape], "");
539        assert!(h.ui.closed("main"));
540        assert!(!h.ui.is_open());
541    }
542
543    #[test]
544    fn slider_and_first_letter() {
545        let mut h = Harness::new();
546        h.ui.open(
547            Menu::new("opts", "")
548                .slider("vol", "Volume", 50.0, 0.0, 100.0, 10.0)
549                .checkbox("music", "Music", false)
550                .button("back", "Back"),
551        );
552        h.frame(&[], "");
553        h.frame(&[Key::Right], "");
554        assert_eq!(h.ui.changed("opts", "vol"), Some(&Value::Number(60.0)));
555        assert_eq!(h.ui.value("opts", "vol"), Some(Value::Number(60.0)));
556        h.frame(&[], "b");
557        assert_eq!(h.speech.last(), Some("Back"));
558        h.frame(&[], "m");
559        h.frame(&[Key::Space], "");
560        assert_eq!(h.ui.changed("opts", "music"), Some(&Value::Bool(true)));
561    }
562
563    #[test]
564    fn text_field_in_menu() {
565        let mut h = Harness::new();
566        h.ui.open(Menu::new("m", "").text_field("name", "Name", ""));
567        h.frame(&[], "");
568        h.frame(&[Key::Enter], "");
569        h.frame(&[], ""); // text input opens
570        h.frame(&[], "Bob");
571        h.frame(&[Key::Backspace], "");
572        h.frame(&[Key::Enter], "");
573        h.frame(&[], ""); // menu resumes
574        assert_eq!(h.ui.value("m", "name"), Some(Value::Text("Bo".into())));
575        assert_eq!(h.speech.last(), Some("Name, edit, Bo"));
576    }
577
578    #[test]
579    fn custom_sounds() {
580        let mut h = Harness::new();
581        h.ui.set_sound_data(UiSound::Navigate, crate::audio::Synth::sine(2000.0).length(0.02));
582        h.ui.set_sound(UiSound::Edge, None);
583        assert_eq!(h.ui.sounds().get(UiSound::Navigate), Some("ui:custom:navigate"));
584        assert_eq!(h.ui.sounds().get(UiSound::Wrap), Some("ui:builtin:wrap"));
585        assert_eq!(h.ui.sounds().edge, None);
586        h.ui.open(Menu::new("m", "").button("a", "A").sound(UiSound::Open, None));
587        h.frame(&[], "");
588        assert!(h.audio.is_loaded("ui:custom:navigate"));
589        let only_nav = UiSounds::none().with(UiSound::Navigate, "x");
590        assert_eq!(only_nav.get(UiSound::Wrap), Some("x"));
591    }
592
593    #[test]
594    fn positions_on_request() {
595        let mut h = Harness::new();
596        h.ui.open(Menu::new("m", "").button("a", "A").button("b", "B").positions(true));
597        h.frame(&[], "");
598        assert_eq!(h.speech.last(), Some("A, 1 of 2"));
599    }
600
601    #[test]
602    fn menu_music() {
603        let mut h = Harness::new();
604        let tone = crate::audio::Synth::sine(440.0).length(0.5);
605        for name in ["level", "theme", "options"] {
606            h.audio.register(name, tone.clone());
607        }
608        h.audio.play_music("level");
609        h.ui.open(Menu::new("pause", "Paused").button("opts", "Options").music("theme"));
610        h.frame(&[], "");
611        assert_eq!(h.audio.music_name(), Some("theme"));
612        h.ui.open(Menu::new("sub", "").button("x", "X"));
613        h.frame(&[], "");
614        assert_eq!(h.audio.music_name(), Some("theme"), "submenu without music keeps it");
615        h.ui.open(Menu::new("opts", "").button("x", "X").music("options"));
616        h.frame(&[], "");
617        assert_eq!(h.audio.music_name(), Some("options"));
618        h.ui.close();
619        h.frame(&[], "");
620        assert_eq!(h.audio.music_name(), Some("theme"));
621        h.ui.close_all();
622        h.frame(&[], "");
623        assert_eq!(h.audio.music_name(), Some("level"), "music from before comes back");
624
625        h.ui.open(Menu::new("main", "").button("play", "Play").music("theme"));
626        h.frame(&[], "");
627        h.audio.play_music("options"); // the game starts its own music
628        h.ui.close_all();
629        h.frame(&[], "");
630        assert_eq!(h.audio.music_name(), Some("options"), "game music is left alone");
631    }
632
633    #[test]
634    fn confirm_dialog() {
635        let mut h = Harness::new();
636        h.ui.confirm("quit", "Really quit?");
637        h.frame(&[], "");
638        h.frame(&[Key::Enter], "");
639        assert_eq!(h.ui.confirmed("quit"), Some(true));
640        h.ui.confirm("quit", "Really quit?");
641        h.frame(&[], "");
642        h.frame(&[Key::Escape], "");
643        assert_eq!(h.ui.confirmed("quit"), Some(false));
644    }
645}