shuyi_ui/widgets/ask.rs
1//! A question over the screen: a line of text and the answers to it, one
2//! in focus ("Keep these changes? Keep · Discard · Back"), or a yes/no
3//! answered by Confirm and Cancel alone, its keys named under it
4//! ([`Ask::yes_no`]).
5//!
6//! The screen holds an [`Ask`] while the question is open and hands it
7//! each action ([`Ask::handle`]): the cursor moves between the answers,
8//! Confirm gives the focused one and Cancel the answer it stands for, if
9//! any. What the question shows is [`Ask::view`], plain data, and
10//! [`glyph::paint`] draws that view in a box over whatever is under it.
11//! A farming game's "Sell all 12 turnips?" and a platformer's "Restart the
12//! level?" are the same widget.
13
14pub mod glyph;
15pub mod view;
16
17use crate::prelude::*;
18use core::fmt::Display;
19pub use view::{AnswerView, AskView};
20
21use shuyi_assets::keymap::{ActionSet, UiAction};
22
23use super::list::ListCursor;
24use crate::audio::{AudioQueue, MenuSound};
25use crate::ctx::{Ctx, GameData};
26use crate::input::help_keys::HelpKeys;
27
28/// A question and its answers, each a `T` the screen acts on.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Ask<T> {
31 /// The text key of the question.
32 key: &'static str,
33 /// The answers in the order shown, each with the text key of its name.
34 answers: Vec<(T, &'static str)>,
35 /// Which answer is in focus.
36 cursor: ListCursor,
37 /// What Cancel answers; `None`: Cancel does nothing.
38 cancel: Option<T>,
39 /// The text key of the line naming the keys that answer, shown instead
40 /// of the answers (a yes/no).
41 keys: Option<&'static str>,
42}
43
44impl<T: Copy + PartialEq> Ask<T> {
45 /// What a question reacts to ([`handle`](Self::handle)), for the
46 /// screen that shows it to declare
47 /// ([`Screen::actions`](crate::screen::Screen::actions)).
48 pub const ACTIONS: &'static [UiAction] = crate::input::NAVIGATION;
49
50 /// The question of the text key `key` with `answers` (each an answer
51 /// and the text key of its name), the first in focus. Cancel does
52 /// nothing until [`cancelled_by`](Self::cancelled_by) names an answer.
53 pub fn new(key: &'static str, answers: impl IntoIterator<Item = (T, &'static str)>) -> Self {
54 let answers: Vec<_> = answers.into_iter().collect();
55 Self {
56 key,
57 cursor: ListCursor::new(answers.len()),
58 answers,
59 cancel: None,
60 keys: None,
61 }
62 }
63
64 /// The same question focused on `answer` (on the first while it isn't
65 /// one of the answers).
66 #[must_use]
67 pub fn focused_on(mut self, answer: T) -> Self {
68 let i = self.answers.iter().position(|&(a, _)| a == answer);
69 self.cursor = self.cursor.at(i.unwrap_or(0));
70 self
71 }
72
73 /// The same question, which Cancel answers with `answer` (going back,
74 /// say).
75 #[must_use]
76 pub fn cancelled_by(mut self, answer: T) -> Self {
77 self.cancel = Some(answer);
78 self
79 }
80
81 /// The text key of the question; also what names it.
82 pub fn key(&self) -> &'static str {
83 self.key
84 }
85
86 /// The answer in focus; `None` with no answers.
87 pub fn focus(&self) -> Option<T> {
88 self.answers.get(self.cursor.focus()).map(|&(a, _)| a)
89 }
90
91 /// What Cancel answers, if anything.
92 pub fn cancel(&self) -> Option<T> {
93 self.cancel
94 }
95
96 /// An action while the question is open. The cursor's keys move the
97 /// focus to the next answer (right and down) or the one before (left
98 /// and up), going round at the ends, with the menu's move sound on
99 /// `audio`. Confirm gives the answer in focus, Cancel the answer it
100 /// stands for ([`cancelled_by`](Self::cancelled_by)); anything else
101 /// gives nothing. An answer's own sound is the screen's to play: what
102 /// it does may still be refused.
103 pub fn handle<A: ActionSet, V>(&mut self, action: A, audio: &mut AudioQueue<V>) -> Option<T> {
104 match action.ui()? {
105 UiAction::Up | UiAction::Left => self.step(false, audio),
106 UiAction::Down | UiAction::Right => self.step(true, audio),
107 UiAction::Confirm => return self.focus(),
108 UiAction::Cancel => return self.cancel,
109 UiAction::Debug => {}
110 }
111 None
112 }
113
114 /// Moves the focus one answer on (`forward`) or back, wrapping.
115 fn step<V>(&mut self, forward: bool, audio: &mut AudioQueue<V>) {
116 if self.cursor.step(forward, true, |_| true) {
117 audio.menu(MenuSound::Move);
118 }
119 }
120
121 /// The question as it is now, as plain data for a skin to paint
122 /// ([`glyph::paint`]): its text and its answers, or the line naming
123 /// its keys, in the player's language naming the active keys.
124 pub fn view<G: GameData>(&self, ctx: &Ctx<G>) -> AskView<T> {
125 self.view_with_keys(ctx, ctx.help_keys(), &[])
126 }
127
128 /// [`view`](Self::view), naming the keys `keys` has rather than the
129 /// active ones, and filling the question's placeholders from `args`
130 /// ([`Ctx::text_with_keys`]).
131 pub fn view_with_keys<G: GameData>(
132 &self,
133 ctx: &Ctx<G>,
134 keys: HelpKeys<'_, G::Action>,
135 args: &[(&str, &dyn Display)],
136 ) -> AskView<T> {
137 let focus = self.cursor.focus();
138 let answers = self
139 .answers
140 .iter()
141 .enumerate()
142 .map(|(i, &(answer, key))| AnswerView {
143 answer,
144 label: ctx.text(key).to_owned(),
145 focused: i == focus,
146 });
147 AskView {
148 text: ctx.text_with_keys(self.key, keys, args),
149 answers: match self.keys {
150 Some(_) => Vec::new(),
151 None => answers.collect(),
152 },
153 keys: self.keys.map(|key| ctx.text_with_keys(key, keys, &[])),
154 }
155 }
156}
157
158impl Ask<bool> {
159 /// A yes/no question of the text key `key`: Confirm answers yes
160 /// (`true`) and Cancel no (`false`). It shows no answers to move
161 /// between, but the line of the text key `keys`, which names the keys
162 /// for yes and no.
163 pub fn yes_no(key: &'static str, keys: &'static str) -> Self {
164 Self {
165 keys: Some(keys),
166 ..Self::new(key, [(true, keys)]).cancelled_by(false)
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests;