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