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::speech::Speech;
45
46#[derive(Debug, Clone, PartialEq)]
48pub enum Value {
49 Bool(bool),
51 Number(f32),
53 Choice(usize, String),
55 Text(String),
57}
58
59impl Value {
60 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 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 pub fn as_index(&self) -> usize {
82 self.as_f32().max(0.0).round() as usize
83 }
84
85 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 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#[derive(Debug, Clone, PartialEq)]
110pub enum UiEvent {
111 Activated {
113 menu: String,
115 item: String,
117 },
118 Changed {
120 menu: String,
122 item: String,
124 value: Value,
126 },
127 Closed {
129 id: String,
131 },
132 Confirmed {
134 id: String,
136 yes: bool,
138 },
139 TextSubmitted {
141 id: String,
143 text: String,
145 },
146}
147
148#[derive(Default)]
150pub enum Status {
151 #[default]
153 Open,
154 Close,
156 CloseWith(Value),
159 Push(Box<dyn Widget>),
161}
162
163pub trait Widget: 'static {
165 fn id(&self) -> &str;
167
168 fn open(&mut self, ui: &mut UiContext<'_>);
170
171 fn update(&mut self, ui: &mut UiContext<'_>) -> Status;
173
174 fn resume(&mut self, value: Option<Value>, ui: &mut UiContext<'_>) {
177 let _ = value;
178 self.open(ui);
179 }
180
181 fn value(&self, item: &str) -> Option<Value> {
183 let _ = item;
184 None
185 }
186
187 fn music(&self) -> Option<&str> {
190 None
191 }
192}
193
194pub struct UiContext<'a> {
196 pub input: RawInput<'a>,
198 pub speech: &'a mut Speech,
200 pub audio: &'a mut Audio,
202 sounds: &'a UiSounds,
203 events: &'a mut Vec<UiEvent>,
204}
205
206impl UiContext<'_> {
207 pub fn emit(&mut self, event: UiEvent) {
209 self.events.push(event);
210 }
211
212 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 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 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#[derive(Default)]
246pub struct Ui {
247 stack: Vec<Entry>,
248 events: Vec<UiEvent>,
249 sounds: UiSounds,
250 pending_sounds: Vec<(String, SoundData)>,
252 music: Option<String>,
254 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 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 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 pub fn open(&mut self, widget: impl Widget) {
339 self.stack.push(Entry { widget: Box::new(widget), state: State::Fresh });
340 }
341
342 pub fn open_boxed(&mut self, widget: Box<dyn Widget>) {
344 self.stack.push(Entry { widget, state: State::Fresh });
345 }
346
347 pub fn message(&mut self, id: impl Into<String>, text: impl Into<String>) {
350 self.open(Message::new(id, text));
351 }
352
353 pub fn confirm(&mut self, id: impl Into<String>, question: impl Into<String>) {
355 self.open(Confirm::new(id, question));
356 }
357
358 pub fn input(&mut self, id: impl Into<String>, prompt: impl Into<String>) {
361 self.open(TextInput::new(id, prompt));
362 }
363
364 pub fn close(&mut self) {
366 self.pop(None);
367 }
368
369 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 pub fn close_all(&mut self) {
379 self.stack.clear();
380 }
381
382 pub fn is_open(&self) -> bool {
384 !self.stack.is_empty()
385 }
386
387 pub fn contains(&self, id: &str) -> bool {
389 self.stack.iter().any(|e| e.widget.id() == id)
390 }
391
392 pub fn top(&self) -> Option<&str> {
394 self.stack.last().map(|e| e.widget.id())
395 }
396
397 pub fn set_sounds(&mut self, sounds: UiSounds) {
399 self.sounds = sounds;
400 }
401
402 pub fn sounds(&self) -> &UiSounds {
404 &self.sounds
405 }
406
407 pub fn set_sound(&mut self, kind: UiSound, name: Option<&str>) {
418 self.sounds.set(kind, name.map(String::from));
419 }
420
421 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 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 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 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 pub fn clicked(&self, menu: &str, item: &str) -> bool {
464 self.activated(menu) == Some(item)
465 }
466
467 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 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 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 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(&[], ""); h.frame(&[], "Bob");
571 h.frame(&[Key::Backspace], "");
572 h.frame(&[Key::Enter], "");
573 h.frame(&[], ""); 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"); 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}