1mod 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#[derive(Debug, Clone, PartialEq)]
49pub enum Value {
50 Bool(bool),
52 Number(f32),
54 Choice(usize, String),
56 Text(String),
58}
59
60impl Value {
61 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 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 pub fn as_index(&self) -> usize {
83 self.as_f32().max(0.0).round() as usize
84 }
85
86 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 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#[derive(Debug, Clone, PartialEq)]
111pub enum UiEvent {
112 Activated {
114 menu: String,
116 item: String,
118 },
119 Changed {
121 menu: String,
123 item: String,
125 value: Value,
127 },
128 Closed {
130 id: String,
132 },
133 Confirmed {
135 id: String,
137 yes: bool,
139 },
140 TextSubmitted {
142 id: String,
144 text: String,
146 },
147}
148
149#[derive(Default)]
151pub enum Status {
152 #[default]
154 Open,
155 Close,
157 CloseWith(Value),
160 Push(Box<dyn Widget>),
162}
163
164pub trait Widget: 'static {
166 fn id(&self) -> &str;
168
169 fn open(&mut self, ui: &mut UiContext<'_>);
171
172 fn update(&mut self, ui: &mut UiContext<'_>) -> Status;
174
175 fn resume(&mut self, value: Option<Value>, ui: &mut UiContext<'_>) {
178 let _ = value;
179 self.open(ui);
180 }
181
182 fn value(&self, item: &str) -> Option<Value> {
184 let _ = item;
185 None
186 }
187
188 fn music(&self) -> Option<&str> {
191 None
192 }
193}
194
195pub struct UiContext<'a> {
197 pub input: RawInput<'a>,
199 pub speech: &'a mut Speech,
201 pub audio: &'a mut Audio,
203 pub lang: &'a Lang,
205 sounds: &'a UiSounds,
206 events: &'a mut Vec<UiEvent>,
207}
208
209impl UiContext<'_> {
210 pub fn emit(&mut self, event: UiEvent) {
212 self.events.push(event);
213 }
214
215 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 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 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#[derive(Default)]
249pub struct Ui {
250 stack: Vec<Entry>,
251 events: Vec<UiEvent>,
252 sounds: UiSounds,
253 pending_sounds: Vec<(String, SoundData)>,
255 music: Option<String>,
257 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 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 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 pub fn open(&mut self, widget: impl Widget) {
342 self.stack.push(Entry { widget: Box::new(widget), state: State::Fresh });
343 }
344
345 pub fn open_boxed(&mut self, widget: Box<dyn Widget>) {
347 self.stack.push(Entry { widget, state: State::Fresh });
348 }
349
350 pub fn message(&mut self, id: impl Into<String>, text: impl Into<String>) {
353 self.open(Message::new(id, text));
354 }
355
356 pub fn confirm(&mut self, id: impl Into<String>, question: impl Into<String>) {
358 self.open(Confirm::new(id, question));
359 }
360
361 pub fn input(&mut self, id: impl Into<String>, prompt: impl Into<String>) {
364 self.open(TextInput::new(id, prompt));
365 }
366
367 pub fn close(&mut self) {
369 self.pop(None);
370 }
371
372 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 pub fn close_all(&mut self) {
382 self.stack.clear();
383 }
384
385 pub fn is_open(&self) -> bool {
387 !self.stack.is_empty()
388 }
389
390 pub fn contains(&self, id: &str) -> bool {
392 self.stack.iter().any(|e| e.widget.id() == id)
393 }
394
395 pub fn top(&self) -> Option<&str> {
397 self.stack.last().map(|e| e.widget.id())
398 }
399
400 pub fn set_sounds(&mut self, sounds: UiSounds) {
402 self.sounds = sounds;
403 }
404
405 pub fn sounds(&self) -> &UiSounds {
407 &self.sounds
408 }
409
410 pub fn set_sound(&mut self, kind: UiSound, name: Option<&str>) {
421 self.sounds.set(kind, name.map(String::from));
422 }
423
424 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 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 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 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 pub fn clicked(&self, menu: &str, item: &str) -> bool {
467 self.activated(menu) == Some(item)
468 }
469
470 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 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 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 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(&[], ""); h.frame(&[], "Bob");
575 h.frame(&[Key::Backspace], "");
576 h.frame(&[Key::Enter], "");
577 h.frame(&[], ""); 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"); 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}