Skip to main content

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;