Skip to main content

crcbl_input/
ui.rs

1//! The engine's reserved `ui` context: the navigation actions every UI screen
2//! is driven by, as the reserved `ui_*` table in `docs/notes/tooling.md`
3//! (_What the deleted 07-ui-debug plan left behind_) defines them.
4//!
5//! | Action     | Kind    | Keyboard                      | Gamepad                       | Repeat         |
6//! | ---------- | ------- | ----------------------------- | ----------------------------- | -------------- |
7//! | [`MOVE`]   | `Axis2` | the arrows, and W, A, S and D | the left stick, and the d-pad | [`Repeat::UI`] |
8//! | [`NEXT`]   | Button  | Tab                           | the right shoulder            | [`Repeat::UI`] |
9//! | [`PREV`]   | Button  | Shift+Tab                     | the left shoulder             | [`Repeat::UI`] |
10//! | [`ACCEPT`] | Button  | Enter, Space                  | [`PadButton::South`]          | none           |
11//! | [`BACK`]   | Button  | Escape                        | [`PadButton::East`]           | none           |
12//! | [`MENU`]   | Button  | the menu key, Shift+F10       | none                          | none           |
13//!
14//! **Rebindable like every action** — they are ordinary actions in an ordinary
15//! context, so [`ActionMap::rebind`] moves them.
16//!
17//! **Pushed only while a UI has input.** Declaring the context leaves it off
18//! the stack; whoever owns a UI's input pushes [`CONTEXT`] while that UI has
19//! it and pops it after, and while it is pushed the arrows, WASD, Tab, Enter,
20//! Space and Escape — and the pad's left stick, d-pad, shoulders, South and
21//! East — are the UI's and not the game's beneath: the context stack is the
22//! disambiguator, not a list of special cases.
23//!
24//! **The stick reads through [`STICK_DEADZONE`]**, so a stick resting off
25//! centre does not walk a list. **The d-pad is a [`Binding::PadDpad`]**, the
26//! pad's [`Binding::Wasd`], so it shares one unit disc with the stick and the
27//! keys.
28//!
29//! **Start is not in the table.** Like Escape's pause, it is the engine loop's,
30//! and the loop binds it outside this context.
31//!
32//! **[`MENU`] has no pad binding.** It opens a context menu, which a pad
33//! reaches through no convention every platform shares; and a pad button
34//! bound here is one a pushed context takes from the game beneath.
35
36use super::{
37    ActionDecl, ActionKind, ActionMap, ActionMapError, Binding, Modifier, PAD_ACTIVITY_THRESHOLD,
38    PadButton, Repeat, Stick,
39};
40use crcbl_core::input::KeyCode;
41
42/// The reserved context's name.
43pub const CONTEXT: &str = "ui";
44/// Spatial focus movement: an `Axis2`, +Y up.
45pub const MOVE: &str = "ui_move";
46/// Forward in tree order.
47pub const NEXT: &str = "ui_next";
48/// Backward in tree order.
49pub const PREV: &str = "ui_prev";
50/// Fire a button, engage a widget, or commit the engaged one.
51pub const ACCEPT: &str = "ui_accept";
52/// Cancel the engaged widget, or close the screen.
53pub const BACK: &str = "ui_back";
54/// Open the focused widget's context menu: the keyboard's menu key, and
55/// Shift+F10 for a keyboard without one — Windows' pair.
56pub const MENU: &str = "ui_menu";
57
58/// Every reserved action, in the table's order.
59pub const ACTIONS: [&str; 6] = [MOVE, NEXT, PREV, ACCEPT, BACK, MENU];
60
61/// The dead zone [`MOVE`]'s left stick reads through: the pad's own activity
62/// threshold, so the stick navigates exactly when it would also make the pad
63/// the last device.
64pub const STICK_DEADZONE: f32 = PAD_ACTIVITY_THRESHOLD;
65
66/// The reserved actions with their default bindings.
67fn declarations() -> [ActionDecl; ACTIONS.len()] {
68    let button = |name: &str, bindings: Vec<Binding>| ActionDecl {
69        name: name.to_owned(),
70        kind: ActionKind::Button,
71        bindings,
72    };
73    [
74        ActionDecl {
75            name: MOVE.to_owned(),
76            kind: ActionKind::Axis2,
77            bindings: vec![
78                Binding::Wasd {
79                    up: KeyCode::ArrowUp,
80                    down: KeyCode::ArrowDown,
81                    left: KeyCode::ArrowLeft,
82                    right: KeyCode::ArrowRight,
83                },
84                Binding::Wasd {
85                    up: KeyCode::KeyW,
86                    down: KeyCode::KeyS,
87                    left: KeyCode::KeyA,
88                    right: KeyCode::KeyD,
89                },
90                Binding::PadStick {
91                    stick: Stick::Left,
92                    deadzone: STICK_DEADZONE,
93                },
94                Binding::PadDpad,
95            ],
96        },
97        button(
98            NEXT,
99            vec![
100                Binding::Key(KeyCode::Tab),
101                Binding::PadButton(PadButton::RightShoulder),
102            ],
103        ),
104        button(
105            PREV,
106            vec![
107                Binding::Chord {
108                    modifier: Modifier::Shift,
109                    key: KeyCode::Tab,
110                },
111                Binding::PadButton(PadButton::LeftShoulder),
112            ],
113        ),
114        button(
115            ACCEPT,
116            vec![
117                Binding::Key(KeyCode::Enter),
118                Binding::Key(KeyCode::Space),
119                Binding::PadButton(PadButton::South),
120            ],
121        ),
122        button(
123            BACK,
124            vec![
125                Binding::Key(KeyCode::Escape),
126                Binding::PadButton(PadButton::East),
127            ],
128        ),
129        button(
130            MENU,
131            vec![
132                Binding::Key(KeyCode::ContextMenu),
133                Binding::Chord {
134                    modifier: Modifier::Shift,
135                    key: KeyCode::F10,
136                },
137            ],
138        ),
139    ]
140}
141
142/// Declare the reserved `ui` context into `map`, off the stack, with
143/// [`Repeat::UI`] on the three navigation actions.
144///
145/// # Errors
146/// [`ActionMapError::DuplicateName`] if `map` already declares any of the
147/// reserved names — checked before any is declared, so a refused call leaves
148/// the map as it was.
149pub fn declare(map: &mut ActionMap) -> Result<(), ActionMapError> {
150    let decls = declarations();
151    if let Some(taken) = decls.iter().find(|decl| map.bindings(&decl.name).is_some()) {
152        return Err(ActionMapError::DuplicateName(taken.name.clone()));
153    }
154    for decl in decls {
155        map.try_declare_in(CONTEXT, decl)?;
156    }
157    for name in [MOVE, NEXT, PREV] {
158        map.set_repeat(name, Some(Repeat::UI))?;
159    }
160    Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::{Cardinal, GAMEPLAY_CONTEXT};
167
168    const TICK: f32 = 1.0 / 60.0;
169
170    fn declared() -> ActionMap {
171        let mut map = ActionMap::new();
172        map.declare(ActionDecl {
173            name: "walk".to_owned(),
174            kind: ActionKind::Axis2,
175            bindings: vec![Binding::Wasd {
176                up: KeyCode::KeyW,
177                down: KeyCode::KeyS,
178                left: KeyCode::KeyA,
179                right: KeyCode::KeyD,
180            }],
181        });
182        map.declare(ActionDecl {
183            name: "jump".to_owned(),
184            kind: ActionKind::Button,
185            bindings: vec![Binding::Key(KeyCode::Space)],
186        });
187        declare(&mut map).expect("nothing reserved is taken");
188        map
189    }
190
191    /// Declared off the stack, in its own context, and inert until pushed:
192    /// the game keeps WASD and Space.
193    #[test]
194    fn the_ui_context_is_declared_off_the_stack() {
195        let mut map = declared();
196        for name in ACTIONS {
197            assert_eq!(map.context_of(name), Some(CONTEXT), "{name}");
198        }
199        assert_eq!(
200            map.active_contexts().collect::<Vec<_>>(),
201            [GAMEPLAY_CONTEXT]
202        );
203        map.key_event(KeyCode::KeyW, true);
204        map.key_event(KeyCode::Space, true);
205        assert_eq!(map.axis2("walk"), (0.0, 1.0));
206        assert!(map.button_held("jump"));
207        assert_eq!(map.cardinal(MOVE), None);
208        assert!(!map.button_held(ACCEPT));
209    }
210
211    /// **Pushed, it takes the table's keys from the game**, both movement
212    /// sets drive one `ui_move`, and Tab and Shift+Tab are distinct.
213    #[test]
214    fn pushed_it_drives_the_reserved_actions_from_the_table() {
215        let mut map = declared();
216        map.push_context(CONTEXT).expect("declared");
217
218        map.begin_tick(TICK);
219        map.key_event(KeyCode::ArrowLeft, true);
220        assert_eq!(map.cardinal(MOVE), Some(Cardinal::Left));
221        map.key_event(KeyCode::ArrowLeft, false);
222        map.key_event(KeyCode::KeyS, true);
223        assert_eq!(map.cardinal(MOVE), Some(Cardinal::Down));
224        assert_eq!(map.axis2("walk"), (0.0, 0.0), "S is the UI's now");
225        map.key_event(KeyCode::KeyS, false);
226
227        for key in [KeyCode::Enter, KeyCode::Space] {
228            map.begin_tick(TICK);
229            map.key_event(key, true);
230            assert!(map.just_pressed(ACCEPT), "{key}");
231            assert!(!map.button_held("jump"));
232            map.key_event(key, false);
233        }
234
235        map.begin_tick(TICK);
236        map.key_event(KeyCode::Escape, true);
237        assert!(map.just_pressed(BACK));
238
239        map.begin_tick(TICK);
240        map.key_event(KeyCode::Tab, true);
241        assert!(map.repeated(NEXT) && !map.button_held(PREV));
242        map.key_event(KeyCode::Tab, false);
243        map.begin_tick(TICK);
244        map.key_event(KeyCode::ShiftRight, true);
245        map.key_event(KeyCode::Tab, true);
246        assert!(map.repeated(PREV), "Shift+Tab is ui_prev");
247        assert!(!map.button_held(NEXT), "and not ui_next as well");
248        map.key_event(KeyCode::Tab, false);
249
250        map.begin_tick(TICK);
251        map.key_event(KeyCode::F10, true);
252        assert!(map.just_pressed(MENU), "Shift+F10 is ui_menu");
253        map.key_event(KeyCode::F10, false);
254        map.key_event(KeyCode::ShiftRight, false);
255        map.begin_tick(TICK);
256        map.key_event(KeyCode::F10, true);
257        assert!(!map.button_held(MENU), "F10 alone is not");
258        map.key_event(KeyCode::F10, false);
259        map.begin_tick(TICK);
260        map.key_event(KeyCode::ContextMenu, true);
261        assert!(map.just_pressed(MENU), "the menu key is ui_menu");
262    }
263
264    /// **Pushed, it takes the table's pad column too**: the left stick past
265    /// the dead zone moves, drift inside it does not, each d-pad direction
266    /// steps its way, the shoulders step through tree order, South accepts and
267    /// East backs out.
268    #[test]
269    fn pushed_it_drives_the_reserved_actions_from_a_pad() {
270        use crate::{GamepadEvent, GamepadId, GamepadSnapshot, PadAxis, PadKind};
271        let pad = GamepadId(3);
272        let at_rest = GamepadSnapshot::neutral(PadKind::Xbox);
273        let state = |snapshot| GamepadEvent::State { id: pad, snapshot };
274        let holding = |button: PadButton| GamepadSnapshot {
275            buttons: [button].into_iter().collect(),
276            ..at_rest
277        };
278        let mut map = declared();
279        map.push_context(CONTEXT).expect("declared");
280
281        map.begin_tick(TICK);
282        let mut drift = at_rest;
283        drift.axes[PadAxis::LeftY as usize] = -STICK_DEADZONE / 2.0;
284        map.gamepad_event(&state(drift));
285        assert_eq!(map.cardinal(MOVE), None, "drift inside the dead zone");
286        let mut down = at_rest;
287        down.axes[PadAxis::LeftY as usize] = -1.0;
288        map.gamepad_event(&state(down));
289        assert_eq!(map.cardinal(MOVE), Some(Cardinal::Down));
290        assert_eq!(map.axis2("walk"), (0.0, 0.0), "the game binds no stick");
291        map.gamepad_event(&state(at_rest));
292
293        for (button, direction) in [
294            (PadButton::DpadUp, Cardinal::Up),
295            (PadButton::DpadDown, Cardinal::Down),
296            (PadButton::DpadLeft, Cardinal::Left),
297            (PadButton::DpadRight, Cardinal::Right),
298        ] {
299            map.begin_tick(TICK);
300            map.gamepad_event(&state(holding(button)));
301            assert_eq!(map.cardinal(MOVE), Some(direction), "{button:?}");
302            assert!(map.repeated(MOVE), "{button:?} steps once on the press");
303            map.gamepad_event(&state(at_rest));
304        }
305
306        for (button, action) in [
307            (PadButton::RightShoulder, NEXT),
308            (PadButton::LeftShoulder, PREV),
309            (PadButton::South, ACCEPT),
310            (PadButton::East, BACK),
311        ] {
312            map.begin_tick(TICK);
313            map.gamepad_event(&state(holding(button)));
314            assert!(map.just_pressed(action), "{button:?} is {action}");
315            map.gamepad_event(&state(at_rest));
316        }
317    }
318
319    /// The reserved actions rebind like any other.
320    #[test]
321    fn the_reserved_actions_rebind() {
322        let mut map = declared();
323        map.push_context(CONTEXT).expect("declared");
324        map.rebind(ACCEPT, vec![Binding::Key(KeyCode::KeyE)])
325            .expect("declared");
326        map.key_event(KeyCode::KeyE, true);
327        map.key_event(KeyCode::Space, true);
328        assert!(map.button_held(ACCEPT));
329        assert!(
330            map.button_held("jump"),
331            "Space is no longer the UI's, so it falls through"
332        );
333    }
334
335    /// A clash is refused whole, before anything is declared.
336    #[test]
337    fn a_clash_declares_nothing() {
338        let mut map = ActionMap::new();
339        map.declare(ActionDecl {
340            name: BACK.to_owned(),
341            kind: ActionKind::Button,
342            bindings: vec![],
343        });
344        assert_eq!(
345            declare(&mut map),
346            Err(ActionMapError::DuplicateName(BACK.to_owned())),
347        );
348        assert_eq!(map.action_names().collect::<Vec<_>>(), [BACK]);
349    }
350}