Skip to main content

shuyi_ui/input/
fit.rs

1//! Whether a game's controls fit a target (ADR-0008 ยง3): on each screen,
2//! the required actions it declares ([`Screen::actions`]) must bind to
3//! *distinct* inputs the target's profile offers.
4//!
5//! [`controls_fit`] is the check, pure: a target's profile, the default
6//! buttons the game plays it with (the keymap file's table named after
7//! the target, [`KeymapDef::target_buttons`]) and the screens' actions in,
8//! every [`FitProblem`] out. It fails when:
9//!
10//! - a binding uses an input the profile lacks: a button it doesn't have,
11//!   a chord it doesn't list, a system menu item beyond its slots;
12//! - a required action a screen declares has no input the profile offers;
13//! - two required actions of one screen share an input.
14//!
15//! **What counts as an input** is the profile's: its `buttons` (a crank's
16//! notches among them, `CrankForward` and `CrankBack`), and each chord it
17//! lists as one more. **What doesn't:** long and double presses (a keymap
18//! can't say them), chords the profile doesn't list, the system's own
19//! buttons (no keymap can name them: the Playdate's Menu and Lock, the
20//! Deck's Steam and Quick Access), and the system menu's items
21//! (`SystemMenu0` and on), which may hold optional actions only.
22//! The engine never folds actions onto fewer buttons to make a game fit:
23//! the game redesigns the screen.
24//!
25//! [`GameControls`] holds a game's keymap and screens for `shuyi check
26//! --target`, which asks it through the [`Controls`] trait, by target.
27//!
28//! [`Screen::actions`]: crate::screen::Screen::actions
29
30use crate::prelude::*;
31use alloc::collections::BTreeMap;
32
33use shuyi_assets::keymap::{ActionSet, ButtonChord, KeymapDef, PadKeys};
34use shuyi_assets::target::TargetProfile;
35
36use crate::ctx::GameData;
37use crate::screen::Screen;
38
39/// One screen's actions, as the check sees them.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ScreenActions<A> {
42    /// The screen's [`name`](Screen::name).
43    pub screen: &'static str,
44    /// What it declares ([`Screen::actions`]); `None` if it doesn't, and
45    /// then every action counts as its.
46    pub actions: Option<Vec<A>>,
47}
48
49impl<A: ActionSet> ScreenActions<A> {
50    /// `screen`'s name and the actions it declares.
51    pub fn of<G: GameData<Action = A>>(screen: &dyn Screen<G>) -> Self {
52        Self {
53            screen: screen.name(),
54            actions: screen.actions(),
55        }
56    }
57
58    /// The screen named `screen`, declaring `actions`.
59    pub fn declared(screen: &'static str, actions: Vec<A>) -> Self {
60        Self {
61            screen,
62            actions: Some(actions),
63        }
64    }
65
66    /// Its required actions, each once, in [`ActionSet::ALL`] order.
67    fn required(&self) -> impl Iterator<Item = A> + '_ {
68        A::ALL.iter().copied().filter(|a| {
69            a.is_required()
70                && self
71                    .actions
72                    .as_ref()
73                    .is_none_or(|declared| declared.contains(a))
74        })
75    }
76}
77
78/// Which rule a [`FitProblem`] breaks.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
80pub enum FitRule {
81    /// The keymap has no buttons for a target that has no keyboard.
82    NoLayout,
83    /// A binding uses a button the target doesn't have, or a chord it
84    /// doesn't list.
85    NotOnTarget,
86    /// A binding uses a system menu item beyond the target's slots.
87    MenuSlot,
88    /// A required action of a screen has no input the target offers.
89    Unreachable,
90    /// Two required actions of one screen share an input.
91    Shared,
92    /// A screen doesn't declare its actions, so every action counts as
93    /// its: a warning, not an error.
94    Undeclared,
95}
96
97impl FitRule {
98    /// Whether it fails the check (all but [`Undeclared`](Self::Undeclared)).
99    pub fn is_error(self) -> bool {
100        self != Self::Undeclared
101    }
102}
103
104/// A way a game's controls don't fit a target, naming what it is about.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct FitProblem {
107    /// The rule it breaks.
108    pub rule: FitRule,
109    /// The screen it is about, if one.
110    pub screen: Option<&'static str>,
111    /// The actions it is about, by name.
112    pub actions: Vec<&'static str>,
113    /// The inputs it is about, as the keymap file names them (`East`,
114    /// `East+West`).
115    pub inputs: Vec<String>,
116    /// What is wrong, with the names.
117    pub message: String,
118}
119
120impl FitProblem {
121    /// Whether it fails the check ([`FitRule::is_error`]).
122    pub fn is_error(&self) -> bool {
123        self.rule.is_error()
124    }
125}
126
127/// What a binding is on a target.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129enum Offer {
130    /// An input of the target's: a button it has, or a chord it lists.
131    Input,
132    /// One of the target's system menu items: for optional actions only.
133    Menu,
134    /// A button the target doesn't have, or a chord it doesn't list.
135    Lacking,
136    /// A system menu item beyond the target's slots.
137    PastMenu,
138}
139
140/// What `binding` is on `profile`'s target.
141fn offer(profile: &TargetProfile, binding: ButtonChord) -> Offer {
142    let input = &profile.input;
143    if binding.buttons().is_some() {
144        let listed = input
145            .chords
146            .iter()
147            .any(|&[a, b]| ButtonChord::pair(a, b) == Some(binding));
148        return if listed { Offer::Input } else { Offer::Lacking };
149    }
150    let button = binding.first();
151    match button.system_menu_slot() {
152        Some(slot) if slot < input.system_menu_slots => Offer::Menu,
153        Some(_) => Offer::PastMenu,
154        None if input.buttons.contains(&button) => Offer::Input,
155        None => Offer::Lacking,
156    }
157}
158
159/// `names` as a sentence: `"a"`, `"a" and "b"`, `"a", "b" and "c"`.
160fn quoted_list(names: &[&str]) -> String {
161    let quoted: Vec<String> = names.iter().map(|n| format!("\"{n}\"")).collect();
162    match quoted.split_last() {
163        Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
164        Some((last, _)) => last.clone(),
165        None => String::new(),
166    }
167}
168
169/// Whether the controls of `layout` (a target's default buttons) fit
170/// `profile` on each of `screens`: every problem, the layout's first (in
171/// action order), then each screen's in the order given (see the [module
172/// docs](self)).
173pub fn controls_fit<A: ActionSet>(
174    profile: &TargetProfile,
175    layout: &PadKeys<A>,
176    screens: &[ScreenActions<A>],
177) -> Vec<FitProblem> {
178    let target = profile.id.as_str();
179    let mut out = Vec::new();
180    for (&action, bindings) in layout {
181        for &binding in bindings {
182            let name = action.name();
183            let (rule, message) = match offer(profile, binding) {
184                Offer::Input | Offer::Menu => continue,
185                Offer::Lacking if binding.buttons().is_some() => (
186                    FitRule::NotOnTarget,
187                    format!(
188                        "action \"{name}\" is bound to the chord {binding}, which {target} \
189                         doesn't list"
190                    ),
191                ),
192                Offer::Lacking => (
193                    FitRule::NotOnTarget,
194                    format!("action \"{name}\" is bound to {binding}, which {target} doesn't have"),
195                ),
196                Offer::PastMenu => (
197                    FitRule::MenuSlot,
198                    format!(
199                        "action \"{name}\" is bound to {binding}, but {target}'s system menu \
200                         holds {} item(s) of the game's",
201                        profile.input.system_menu_slots
202                    ),
203                ),
204            };
205            out.push(FitProblem {
206                rule,
207                screen: None,
208                actions: vec![name],
209                inputs: vec![binding.to_string()],
210                message,
211            });
212        }
213    }
214    for screen in screens {
215        let name = screen.screen;
216        let mut on: BTreeMap<ButtonChord, Vec<&'static str>> = BTreeMap::new();
217        for action in screen.required() {
218            let bindings = layout.get(&action).map_or(&[][..], Vec::as_slice);
219            let usable: Vec<ButtonChord> = bindings
220                .iter()
221                .copied()
222                .filter(|&b| offer(profile, b) == Offer::Input)
223                .collect();
224            if usable.is_empty() {
225                let why = if bindings.is_empty() {
226                    "it has no button".to_owned()
227                } else if bindings.iter().all(|&b| offer(profile, b) == Offer::Menu) {
228                    "a system menu item holds optional actions only".to_owned()
229                } else {
230                    let shown: Vec<String> = bindings.iter().map(ToString::to_string).collect();
231                    format!("it is bound to {}", shown.join(", "))
232                };
233                out.push(FitProblem {
234                    rule: FitRule::Unreachable,
235                    screen: Some(name),
236                    actions: vec![action.name()],
237                    inputs: bindings.iter().map(ToString::to_string).collect(),
238                    message: format!(
239                        "screen \"{name}\": required action \"{}\" has no input {target} offers \
240                         ({why})",
241                        action.name()
242                    ),
243                });
244            }
245            for binding in usable {
246                on.entry(binding).or_default().push(action.name());
247            }
248        }
249        for (binding, actions) in on {
250            if actions.len() > 1 {
251                out.push(FitProblem {
252                    rule: FitRule::Shared,
253                    screen: Some(name),
254                    message: format!(
255                        "screen \"{name}\": required actions {} share {binding} on {target}",
256                        quoted_list(&actions)
257                    ),
258                    actions,
259                    inputs: vec![binding.to_string()],
260                });
261            }
262        }
263    }
264    out
265}
266
267/// A game's controls as `shuyi check --target` checks them, by target
268/// (`shuyi_cli::GameTools::controls`).
269pub trait Controls {
270    /// Every problem of the game's controls on the target `profile`
271    /// describes ([`GameControls::fit`]).
272    fn fit(&self, profile: &TargetProfile) -> Vec<FitProblem>;
273}
274
275/// A game's keymap and its screens, for the check on every target.
276#[derive(Debug, Clone)]
277pub struct GameControls<A: ActionSet> {
278    keymap: KeymapDef<A>,
279    screens: Vec<ScreenActions<A>>,
280}
281
282impl<A: ActionSet> GameControls<A> {
283    /// The game whose default bindings are `keymap`, with no screens yet.
284    pub fn new(keymap: KeymapDef<A>) -> Self {
285        Self {
286            keymap,
287            screens: Vec::new(),
288        }
289    }
290
291    /// The same, with `screen` too. Every screen the game can show is
292    /// listed, overlays and popups on their own.
293    #[must_use]
294    pub fn with(mut self, screen: ScreenActions<A>) -> Self {
295        self.screens.push(screen);
296        self
297    }
298
299    /// The screens listed, in order.
300    pub fn screens(&self) -> &[ScreenActions<A>] {
301        &self.screens
302    }
303
304    /// Every problem on `profile`'s target: a warning for each screen that
305    /// doesn't declare its actions, then [`controls_fit`] on the keymap's
306    /// table for the target. A target without a table plays with the keys
307    /// if it has a keyboard (the keymap file's own check covers them), and
308    /// fails if it hasn't.
309    pub fn fit(&self, profile: &TargetProfile) -> Vec<FitProblem> {
310        let target = profile.id.as_str();
311        let mut out: Vec<FitProblem> = self
312            .screens
313            .iter()
314            .filter(|s| s.actions.is_none())
315            .map(|s| FitProblem {
316                rule: FitRule::Undeclared,
317                screen: Some(s.screen),
318                actions: Vec::new(),
319                inputs: Vec::new(),
320                message: format!(
321                    "screen \"{}\" doesn't declare its actions (Screen::actions): every action \
322                     counts as its",
323                    s.screen
324                ),
325            })
326            .collect();
327        match self.keymap.target_buttons(target) {
328            Some(layout) => out.extend(controls_fit(profile, layout, &self.screens)),
329            None if profile.input.keyboard => {}
330            None => out.push(FitProblem {
331                rule: FitRule::NoLayout,
332                screen: None,
333                actions: Vec::new(),
334                inputs: Vec::new(),
335                message: format!(
336                    "the keymap file has no buttons for {target}, which has no keyboard: give \
337                     it a table in `targets` (\"{target}\")"
338                ),
339            }),
340        }
341        out
342    }
343}
344
345impl<A: ActionSet> Controls for GameControls<A> {
346    fn fit(&self, profile: &TargetProfile) -> Vec<FitProblem> {
347        GameControls::fit(self, profile)
348    }
349}
350
351#[cfg(test)]
352mod tests;