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