1use super::dialog::TextInput;
4use super::{Status, UiContext, UiEvent, UiSound, UiSounds, Value, Widget, format_number};
5use crate::lang::Lang;
6use crate::input::Key;
7
8#[derive(Debug, Clone, PartialEq)]
10pub enum ItemKind {
11 Button,
13 Checkbox(bool),
15 Slider {
17 value: f32,
19 min: f32,
21 max: f32,
23 step: f32,
25 },
26 Choice {
28 options: Vec<String>,
30 selected: usize,
32 },
33 Text {
35 value: String,
37 password: bool,
39 },
40 Label,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub struct MenuItem {
47 pub id: String,
49 pub label: String,
51 pub kind: ItemKind,
53}
54
55impl MenuItem {
56 pub fn value(&self) -> Option<Value> {
58 match &self.kind {
59 ItemKind::Checkbox(b) => Some(Value::Bool(*b)),
60 ItemKind::Slider { value, .. } => Some(Value::Number(*value)),
61 ItemKind::Choice { options, selected } => {
62 Some(Value::Choice(*selected, options.get(*selected).cloned().unwrap_or_default()))
63 }
64 ItemKind::Text { value, .. } => Some(Value::Text(value.clone())),
65 ItemKind::Button | ItemKind::Label => None,
66 }
67 }
68
69 pub fn describe(&self) -> String {
71 self.describe_in(&Lang::new())
72 }
73
74 pub fn describe_in(&self, lang: &Lang) -> String {
76 match &self.kind {
77 ItemKind::Button | ItemKind::Label => self.label.clone(),
78 ItemKind::Checkbox(b) => format!("{}, {}", self.label, lang.get(if *b { "ui.checked" } else { "ui.not_checked" })),
79 ItemKind::Slider { value, .. } => format!("{}, {}", self.label, format_number(*value)),
80 ItemKind::Choice { options, selected } => {
81 format!("{}, {}", self.label, options.get(*selected).map_or("", String::as_str))
82 }
83 ItemKind::Text { value, password } => {
84 let shown = if value.is_empty() {
85 lang.get("ui.blank")
86 } else if *password {
87 lang.plural("ui.characters", value.chars().count() as i64)
88 } else {
89 value.clone()
90 };
91 lang.format("ui.edit", &[("label", &self.label), ("value", &shown)])
92 }
93 }
94 }
95}
96
97#[derive(Debug, Clone)]
120pub struct Menu {
121 id: String,
122 title: String,
123 items: Vec<MenuItem>,
124 index: usize,
125 wrap: bool,
126 closable: bool,
127 positions: bool,
128 close_on_activate: bool,
129 sounds: Option<UiSounds>,
130 sound_overrides: Vec<(UiSound, Option<String>)>,
131 music: Option<String>,
132 editing: Option<usize>,
133}
134
135impl Menu {
136 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
138 Self {
139 id: id.into(),
140 title: title.into(),
141 items: Vec::new(),
142 index: 0,
143 wrap: false,
144 closable: true,
145 positions: false,
146 close_on_activate: false,
147 sounds: None,
148 sound_overrides: Vec::new(),
149 music: None,
150 editing: None,
151 }
152 }
153
154 pub fn item(mut self, item: MenuItem) -> Self {
156 self.items.push(item);
157 self
158 }
159
160 fn push(self, id: impl Into<String>, label: impl Into<String>, kind: ItemKind) -> Self {
161 self.item(MenuItem { id: id.into(), label: label.into(), kind })
162 }
163
164 pub fn button(self, id: impl Into<String>, label: impl Into<String>) -> Self {
166 self.push(id, label, ItemKind::Button)
167 }
168
169 pub fn checkbox(self, id: impl Into<String>, label: impl Into<String>, checked: bool) -> Self {
171 self.push(id, label, ItemKind::Checkbox(checked))
172 }
173
174 pub fn slider(self, id: impl Into<String>, label: impl Into<String>, value: f32, min: f32, max: f32, step: f32) -> Self {
179 let value = value.clamp(min, max);
180 self.push(id, label, ItemKind::Slider { value, min, max, step: step.abs().max(f32::EPSILON) })
181 }
182
183 pub fn choice(self, id: impl Into<String>, label: impl Into<String>, options: &[&str], selected: usize) -> Self {
186 let options: Vec<String> = options.iter().map(|s| s.to_string()).collect();
187 let selected = selected.min(options.len().saturating_sub(1));
188 self.push(id, label, ItemKind::Choice { options, selected })
189 }
190
191 pub fn text_field(self, id: impl Into<String>, label: impl Into<String>, value: impl Into<String>) -> Self {
193 self.push(id, label, ItemKind::Text { value: value.into(), password: false })
194 }
195
196 pub fn password_field(self, id: impl Into<String>, label: impl Into<String>) -> Self {
198 self.push(id, label, ItemKind::Text { value: String::new(), password: true })
199 }
200
201 pub fn label(self, text: impl Into<String>) -> Self {
203 let text = text.into();
204 self.push("", text, ItemKind::Label)
205 }
206
207 pub fn wrap(mut self, wrap: bool) -> Self {
209 self.wrap = wrap;
210 self
211 }
212
213 pub fn closable(mut self, closable: bool) -> Self {
215 self.closable = closable;
216 self
217 }
218
219 pub fn positions(mut self, positions: bool) -> Self {
221 self.positions = positions;
222 self
223 }
224
225 pub fn close_on_activate(mut self, close: bool) -> Self {
227 self.close_on_activate = close;
228 self
229 }
230
231 pub fn sounds(mut self, sounds: UiSounds) -> Self {
233 self.sounds = Some(sounds);
234 self
235 }
236
237 pub fn sound(mut self, kind: UiSound, name: Option<&str>) -> Self {
240 self.sound_overrides.push((kind, name.map(String::from)));
241 self
242 }
243
244 pub fn music(mut self, name: impl Into<String>) -> Self {
248 self.music = Some(name.into());
249 self
250 }
251
252 fn play(&self, ui: &mut UiContext<'_>, kind: UiSound) {
253 match self.sound_overrides.iter().rev().find(|(k, _)| *k == kind) {
254 Some((_, Some(name))) => ui.play_named(name),
255 Some((_, None)) => {}
256 None => ui.play(kind, self.sounds.as_ref()),
257 }
258 }
259
260 pub fn focus(mut self, id: &str) -> Self {
262 if let Some(i) = self.items.iter().position(|it| it.id == id) {
263 self.index = i;
264 }
265 self
266 }
267
268 pub fn items(&self) -> &[MenuItem] {
270 &self.items
271 }
272
273 pub fn current(&self) -> Option<&MenuItem> {
275 self.items.get(self.index)
276 }
277
278 fn announce(&self, ui: &mut UiContext<'_>, with_title: bool) {
279 let mut text = String::new();
280 if with_title && !self.title.is_empty() {
281 text.push_str(&self.title);
282 text.push_str(". ");
283 }
284 match self.current() {
285 Some(item) => {
286 text.push_str(&item.describe_in(ui.lang));
287 if self.positions {
288 let (index, count) = (self.index + 1, self.items.len());
289 text.push_str(", ");
290 text.push_str(&ui.lang.format("ui.position", &[("index", &index), ("count", &count)]));
291 }
292 }
293 None => text.push_str(&ui.lang.get("ui.empty")),
294 }
295 ui.speech.speak(text);
296 }
297
298 fn speak_value(&self, ui: &mut UiContext<'_>) {
299 if let Some(item) = self.current() {
300 match item.value() {
301 Some(Value::Bool(b)) => ui.speech.speak(ui.lang.get(if b { "ui.checked" } else { "ui.not_checked" })),
302 Some(v) => ui.speech.speak(v.to_text()),
303 None => {}
304 }
305 }
306 }
307
308 fn move_to(&mut self, index: usize, ui: &mut UiContext<'_>) {
309 self.move_with_sound(index, UiSound::Navigate, ui);
310 }
311
312 fn move_with_sound(&mut self, index: usize, sound: UiSound, ui: &mut UiContext<'_>) {
313 if index == self.index {
314 self.play(ui, UiSound::Edge);
315 } else {
316 self.play(ui, sound);
317 self.index = index;
318 }
319 self.announce(ui, false);
320 }
321
322 fn changed(&mut self, ui: &mut UiContext<'_>) {
323 self.play(ui, UiSound::Change);
324 self.speak_value(ui);
325 if let Some(item) = self.current()
326 && let Some(value) = item.value()
327 {
328 let event = UiEvent::Changed { menu: self.id.clone(), item: item.id.clone(), value };
329 ui.emit(event);
330 }
331 }
332
333 fn adjust(&mut self, steps: i32) -> bool {
335 let Some(item) = self.items.get_mut(self.index) else {
336 return false;
337 };
338 match &mut item.kind {
339 ItemKind::Checkbox(b) => {
340 *b = !*b;
341 true
342 }
343 ItemKind::Slider { value, min, max, step } => {
344 let new = (*value + *step * steps as f32).clamp(*min, *max);
345 let new = ((new - *min) / *step).round() * *step + *min;
347 let new = new.clamp(*min, *max);
348 let changed = new != *value;
349 *value = new;
350 changed
351 }
352 ItemKind::Choice { options, selected } => {
353 if options.is_empty() {
354 return false;
355 }
356 let n = options.len() as i32;
357 let new = (*selected as i32 + steps).rem_euclid(n) as usize;
358 let changed = new != *selected;
359 *selected = new;
360 changed
361 }
362 _ => false,
363 }
364 }
365
366 fn jump_to_letter(&mut self, c: char, ui: &mut UiContext<'_>) {
367 let n = self.items.len();
368 let c = c.to_lowercase().next().unwrap_or(c);
369 let found = (1..=n).map(|off| (self.index + off) % n).find(|&i| {
370 self.items[i].label.chars().next().and_then(|f| f.to_lowercase().next()) == Some(c)
371 });
372 match found {
373 Some(i) if i != self.index => {
374 self.index = i;
375 self.play(ui, UiSound::Navigate);
376 self.announce(ui, false);
377 }
378 Some(_) => self.announce(ui, false),
379 None => self.play(ui, UiSound::Edge),
380 }
381 }
382}
383
384impl Widget for Menu {
385 fn id(&self) -> &str {
386 &self.id
387 }
388
389 fn open(&mut self, ui: &mut UiContext<'_>) {
390 self.index = self.index.min(self.items.len().saturating_sub(1));
391 self.play(ui, UiSound::Open);
392 self.announce(ui, true);
393 }
394
395 fn resume(&mut self, value: Option<Value>, ui: &mut UiContext<'_>) {
396 if let (Some(i), Some(Value::Text(text))) = (self.editing.take(), value)
397 && let Some(item) = self.items.get_mut(i)
398 && let ItemKind::Text { value, .. } = &mut item.kind
399 {
400 *value = text.clone();
401 let event = UiEvent::Changed { menu: self.id.clone(), item: item.id.clone(), value: Value::Text(text) };
402 ui.emit(event);
403 }
404 self.announce(ui, false);
405 }
406
407 fn update(&mut self, ui: &mut UiContext<'_>) -> Status {
408 if self.items.is_empty() {
409 if self.closable && ui.key(Key::Escape) {
410 ui.emit(UiEvent::Closed { id: self.id.clone() });
411 return Status::Close;
412 }
413 return Status::Open;
414 }
415 let last = self.items.len() - 1;
416
417 if ui.key(Key::Up) {
418 if self.index > 0 {
419 self.move_to(self.index - 1, ui);
420 } else if self.wrap && last > 0 {
421 self.move_with_sound(last, UiSound::Wrap, ui);
422 } else {
423 self.move_to(0, ui);
424 }
425 } else if ui.key(Key::Down) || ui.key(Key::Tab) {
426 if self.index < last {
427 self.move_to(self.index + 1, ui);
428 } else if self.wrap && last > 0 {
429 self.move_with_sound(0, UiSound::Wrap, ui);
430 } else {
431 self.move_to(last, ui);
432 }
433 } else if ui.key(Key::Home) {
434 self.move_to(0, ui);
435 } else if ui.key(Key::End) {
436 self.move_to(last, ui);
437 } else if ui.key(Key::Left) || ui.key(Key::Right) || ui.key(Key::PageUp) || ui.key(Key::PageDown) {
438 let steps: i32 = if ui.key(Key::Left) {
439 -1
440 } else if ui.key(Key::Right) {
441 1
442 } else if ui.key(Key::PageUp) {
443 10
444 } else {
445 -10
446 };
447 let is_slider = matches!(self.current().map(|i| &i.kind), Some(ItemKind::Slider { .. }));
448 let is_paging = steps.abs() == 10;
449 if is_paging && !is_slider {
450 let to = if steps > 0 { 0 } else { last };
451 self.move_to(to, ui);
452 } else if self.adjust(steps) {
453 self.changed(ui);
454 } else if is_slider || matches!(self.current().map(|i| &i.kind), Some(ItemKind::Choice { .. })) {
455 self.play(ui, UiSound::Edge);
456 self.speak_value(ui);
457 }
458 } else if ui.key(Key::Enter) || ui.key(Key::NumpadEnter) || ui.input.pressed(Key::Space) {
459 let item = &self.items[self.index];
460 match &item.kind {
461 ItemKind::Button => {
462 self.play(ui, UiSound::Activate);
463 ui.emit(UiEvent::Activated { menu: self.id.clone(), item: item.id.clone() });
464 if self.close_on_activate {
465 return Status::Close;
466 }
467 }
468 ItemKind::Checkbox(_) | ItemKind::Choice { .. } => {
469 if self.adjust(1) {
470 self.changed(ui);
471 }
472 }
473 ItemKind::Text { value, password } => {
474 self.play(ui, UiSound::Activate);
475 let mut input = TextInput::new(format!("{}/{}", self.id, item.id), item.label.clone())
476 .initial(value.clone())
477 .child();
478 if *password {
479 input = input.password();
480 }
481 self.editing = Some(self.index);
482 return Status::Push(Box::new(input));
483 }
484 ItemKind::Slider { .. } | ItemKind::Label => self.announce(ui, false),
485 }
486 } else if ui.key(Key::Escape) && self.closable {
487 self.play(ui, UiSound::Close);
488 ui.emit(UiEvent::Closed { id: self.id.clone() });
489 return Status::Close;
490 } else if let Some(c) = ui.input.text().chars().find(|c| c.is_alphanumeric()) {
491 self.jump_to_letter(c, ui);
492 }
493 Status::Open
494 }
495
496 fn value(&self, item: &str) -> Option<Value> {
497 self.items.iter().find(|i| i.id == item).and_then(MenuItem::value)
498 }
499
500 fn music(&self) -> Option<&str> {
501 self.music.as_deref()
502 }
503}