shuyi_ui/widgets/menu.rs
1//! A vertical list menu: a title screen's, an action menu, a list of
2//! replies.
3//!
4//! The menu holds its items and its focus (a [`ListCursor`]). What it
5//! shows is
6//! [`Menu::view`], plain data, and [`glyph::paint`] draws that view as
7//! glyphs: the screens' own skins call it for their menu.
8//!
9//! Each item stands for a value of the game's type, which the menu gives
10//! back when it is chosen, so a screen needs no list beside the menu to
11//! know what was picked. Its label is a [`Label`]: a text key the skin
12//! puts in the player's language when it paints, or text as it is, so a
13//! menu is built once, by logic that doesn't know the language.
14//!
15//! ```
16//! use shuyi_assets::keymap::sample::Action;
17//! use shuyi_ui::widgets::{Menu, MenuEvent, MenuItem};
18//!
19//! #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20//! enum Seed {
21//! Turnip,
22//! Melon,
23//! }
24//!
25//! let mut seeds = Menu::new(vec![
26//! MenuItem::keyed("seed.turnip").with_value(Seed::Turnip),
27//! MenuItem::keyed("seed.melon").with_value(Seed::Melon),
28//! ]);
29//! seeds.handle(Action::CursorDown);
30//! assert_eq!(
31//! seeds.handle(Action::Confirm),
32//! Some(MenuEvent::Chosen(Seed::Melon))
33//! );
34//! ```
35
36pub mod glyph;
37pub mod view;
38
39use crate::prelude::*;
40pub use view::{MenuItemView, MenuView, SuffixKind};
41
42use core::fmt::Display;
43
44use shuyi_assets::keymap::{ActionSet, UiAction};
45
46use super::list::{ListCursor, Scroll};
47use crate::audio::{AudioQueue, MenuSound};
48pub use crate::text::Label;
49
50/// One menu entry, standing for a `value` of the game's type (`()` for
51/// a menu whose screen only needs to know an item was chosen).
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct MenuItem<T = ()> {
54 /// What the item stands for: what [`MenuEvent::Chosen`] gives back.
55 pub value: T,
56 /// Text shown: a text key the skin puts in the player's language, or
57 /// text as it is.
58 pub label: Label,
59 /// Disabled items are drawn dim, skipped by the cursor and can't be chosen.
60 pub enabled: bool,
61 /// Text shown after the label (e.g. `(broken)`), and what it is.
62 pub suffix: Option<(String, SuffixKind)>,
63 /// What the item does, shown under the list while it has the focus
64 /// (a description in a list).
65 pub note: Option<String>,
66}
67
68impl MenuItem {
69 /// An enabled item showing `label` as it is.
70 pub fn new(label: impl Into<String>) -> Self {
71 Self::labelled(Label::Text(label.into()))
72 }
73
74 /// An enabled item showing the text for `key` in the player's
75 /// language. Give its placeholders values with
76 /// [`with_arg`](Self::with_arg).
77 pub fn keyed(key: impl Into<String>) -> Self {
78 Self::labelled(Label::key(key))
79 }
80
81 /// A disabled item showing `label` as it is.
82 pub fn disabled(label: impl Into<String>) -> Self {
83 Self::new(label).with_enabled(false)
84 }
85
86 /// An enabled item showing `label`.
87 pub fn labelled(label: Label) -> Self {
88 Self {
89 value: (),
90 label,
91 enabled: true,
92 suffix: None,
93 note: None,
94 }
95 }
96}
97
98impl<T> MenuItem<T> {
99 /// The same item standing for `value`.
100 #[must_use]
101 pub fn with_value<U>(self, value: U) -> MenuItem<U> {
102 MenuItem {
103 value,
104 label: self.label,
105 enabled: self.enabled,
106 suffix: self.suffix,
107 note: self.note,
108 }
109 }
110
111 /// The same item with the placeholder `{name}` of its keyed label
112 /// filled by `value` (a label of text as it is stays as it is).
113 #[must_use]
114 pub fn with_arg(mut self, name: impl Into<String>, value: impl Display) -> Self {
115 self.label = self.label.with_arg(name, value);
116 self
117 }
118
119 /// The same item, enabled or not.
120 #[must_use]
121 pub fn with_enabled(mut self, enabled: bool) -> Self {
122 self.enabled = enabled;
123 self
124 }
125
126 /// The same item with `text` of `kind` after its label.
127 #[must_use]
128 pub fn with_suffix(mut self, text: impl Into<String>, kind: SuffixKind) -> Self {
129 self.suffix = Some((text.into(), kind));
130 self
131 }
132
133 /// The same item with `text` saying what it does, shown under the
134 /// list while it has the focus.
135 #[must_use]
136 pub fn with_note(mut self, text: impl Into<String>) -> Self {
137 self.note = Some(text.into());
138 self
139 }
140}
141
142/// What a menu reports back to its screen.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum MenuEvent<T = ()> {
145 /// Confirm on an enabled item: the value it stands for. (Its index is
146 /// still the menu's [`focus`](Menu::focus).)
147 Chosen(T),
148 /// Cancel was pressed.
149 Cancelled,
150}
151
152/// A vertical list of items in a single-line box. `CursorDown`/`CursorUp`
153/// move the focus (wrapping, skipping disabled items), `Confirm` chooses the
154/// focused item, `Cancel` cancels (unless the menu is
155/// [`without_cancel`](Self::without_cancel)). A menu
156/// [`showing`](Self::showing) fewer rows than it has scrolls.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct Menu<T = ()> {
159 items: Vec<MenuItem<T>>,
160 cursor: ListCursor,
161 /// How many rows the box shows (all of them if `None`).
162 rows_shown: Option<usize>,
163 /// Whether `Cancel` reports [`MenuEvent::Cancelled`].
164 cancellable: bool,
165 /// Whether the focus also stops on disabled items, so their notes can
166 /// be read (they still can't be chosen).
167 browse_disabled: bool,
168}
169
170impl<T> Menu<T> {
171 /// What a menu reacts to ([`handle`](Self::handle)), for the screen
172 /// that shows it to declare ([`Screen::actions`](crate::screen::Screen::actions)).
173 pub const ACTIONS: &'static [UiAction] = &[
174 UiAction::Up,
175 UiAction::Down,
176 UiAction::Confirm,
177 UiAction::Cancel,
178 ];
179
180 /// A menu focused on its first enabled item (or the first item, if none
181 /// is enabled).
182 pub fn new(items: Vec<MenuItem<T>>) -> Self {
183 let focus = items.iter().position(|i| i.enabled).unwrap_or(0);
184 Self {
185 cursor: ListCursor::new(items.len()).at(focus),
186 items,
187 rows_shown: None,
188 cancellable: true,
189 browse_disabled: false,
190 }
191 }
192
193 /// The same menu where `Cancel` does nothing (and so plays nothing):
194 /// for menus with nothing to back out of.
195 #[must_use]
196 pub fn without_cancel(mut self) -> Self {
197 self.cancellable = false;
198 self
199 }
200
201 /// The same menu where the focus also stops on disabled items, so the
202 /// player can read what they do. Confirm on one still chooses nothing.
203 #[must_use]
204 pub fn browsing_disabled(mut self) -> Self {
205 self.browse_disabled = true;
206 self
207 }
208
209 /// The same menu showing at most `rows` rows: a longer one scrolls,
210 /// its window following the focus by `scroll`.
211 #[must_use]
212 pub fn showing(mut self, rows: usize, scroll: Scroll) -> Self {
213 self.rows_shown = Some(rows);
214 self.cursor = self.cursor.scrolling(scroll);
215 self.follow();
216 self
217 }
218
219 /// The same menu focused on item `index`, if it exists and the focus
220 /// may stop on it (it is enabled, or the menu is
221 /// [`browsing_disabled`](Self::browsing_disabled)).
222 #[must_use]
223 pub fn focused(mut self, index: usize) -> Self {
224 if self.items.get(index).is_some_and(|i| self.stops_on(i)) {
225 self.cursor = self.cursor.at(index);
226 self.follow();
227 }
228 self
229 }
230
231 /// The items, in order.
232 pub fn items(&self) -> &[MenuItem<T>] {
233 &self.items
234 }
235
236 /// The value item `index` stands for, if it exists.
237 pub fn value(&self, index: usize) -> Option<&T> {
238 self.items.get(index).map(|i| &i.value)
239 }
240
241 /// Whether the focus may stop on `item`.
242 fn stops_on(&self, item: &MenuItem<T>) -> bool {
243 item.enabled || self.browse_disabled
244 }
245
246 /// Index of the focused item.
247 pub fn focus(&self) -> usize {
248 self.cursor.focus()
249 }
250
251 /// Handles one action, by what the game [takes it for](ActionSet::ui).
252 /// Returns an event for [`UiAction::Confirm`] on an enabled item (with
253 /// a copy of its value) and for [`UiAction::Cancel`]; moves the focus
254 /// for [`UiAction::Down`] and [`UiAction::Up`]; ignores everything else.
255 pub fn handle<A: ActionSet>(&mut self, action: A) -> Option<MenuEvent<T>>
256 where
257 T: Clone,
258 {
259 match action.ui() {
260 Some(UiAction::Down) => self.step(true),
261 Some(UiAction::Up) => self.step(false),
262 Some(UiAction::Confirm) => {
263 let focus = self.focus();
264 return self
265 .items
266 .get(focus)
267 .filter(|i| i.enabled)
268 .map(|i| MenuEvent::Chosen(i.value.clone()));
269 }
270 Some(UiAction::Cancel) if self.cancellable => return Some(MenuEvent::Cancelled),
271 _ => {}
272 }
273 None
274 }
275
276 /// [`handle`](Self::handle), playing the [`MenuSound`]s on `audio`:
277 /// Move when the focus moves, Select when an item is chosen, Cancel
278 /// when cancelled, Denied for Confirm on a disabled item. Any other
279 /// action that does nothing (a move with nowhere to go) plays nothing.
280 pub fn handle_with_sound<A: ActionSet, V>(
281 &mut self,
282 action: A,
283 audio: &mut AudioQueue<V>,
284 ) -> Option<MenuEvent<T>>
285 where
286 T: Clone,
287 {
288 let before = self.focus();
289 let event = self.handle(action);
290 let sound = match event {
291 Some(MenuEvent::Chosen(_)) => Some(MenuSound::Select),
292 Some(MenuEvent::Cancelled) => Some(MenuSound::Cancel),
293 None if self.focus() != before => Some(MenuSound::Move),
294 None if action.ui() == Some(UiAction::Confirm) && !self.items.is_empty() => {
295 Some(MenuSound::Denied)
296 }
297 None => None,
298 };
299 if let Some(sound) = sound {
300 audio.menu(sound);
301 }
302 event
303 }
304
305 /// Moves the focus to the next (or previous) item it may stop on
306 /// (enabled, unless the menu browses disabled ones too), wrapping.
307 /// Stays put if there is none.
308 fn step(&mut self, down: bool) {
309 let (items, browse) = (&self.items, self.browse_disabled);
310 if self.cursor.step(down, true, |i| items[i].enabled || browse) {
311 self.follow();
312 }
313 }
314
315 /// Moves the window to keep the focus in it, if the menu scrolls.
316 fn follow(&mut self) {
317 if let Some(rows) = self.rows_shown {
318 self.cursor.window(rows);
319 }
320 }
321
322 /// What the menu shows: every item, which one has the focus, and the
323 /// window of them the box shows.
324 pub fn view(&self) -> MenuView {
325 let focus = self.focus();
326 let items = self.items.iter().enumerate().map(|(i, item)| MenuItemView {
327 label: item.label.clone(),
328 suffix: item.suffix.clone(),
329 note: item.note.clone(),
330 enabled: item.enabled,
331 focused: i == focus,
332 });
333 let n = self.items.len();
334 let (first_shown, shown) = match self.rows_shown {
335 // The cursor keeps its window inside the list.
336 Some(rows) => (self.cursor.first_shown(), rows.max(1).min(n)),
337 None => (0, n),
338 };
339 MenuView {
340 items: items.collect(),
341 first_shown,
342 shown,
343 }
344 }
345}
346
347#[cfg(test)]
348mod tests;