crcbl-input 0.17.2

Crucible input system: action mapping, patterns, contexts
Documentation
//! The engine's reserved `ui` context: the navigation actions every UI screen
//! is driven by, as the reserved `ui_*` table in `docs/notes/tooling.md`
//! (_What the deleted 07-ui-debug plan left behind_) defines them.
//!
//! | Action     | Kind    | Keyboard                      | Gamepad                       | Repeat         |
//! | ---------- | ------- | ----------------------------- | ----------------------------- | -------------- |
//! | [`MOVE`]   | `Axis2` | the arrows, and W, A, S and D | the left stick, and the d-pad | [`Repeat::UI`] |
//! | [`NEXT`]   | Button  | Tab                           | the right shoulder            | [`Repeat::UI`] |
//! | [`PREV`]   | Button  | Shift+Tab                     | the left shoulder             | [`Repeat::UI`] |
//! | [`ACCEPT`] | Button  | Enter, Space                  | [`PadButton::South`]          | none           |
//! | [`BACK`]   | Button  | Escape                        | [`PadButton::East`]           | none           |
//! | [`MENU`]   | Button  | the menu key, Shift+F10       | none                          | none           |
//!
//! **Rebindable like every action** — they are ordinary actions in an ordinary
//! context, so [`ActionMap::rebind`] moves them.
//!
//! **Pushed only while a UI has input.** Declaring the context leaves it off
//! the stack; whoever owns a UI's input pushes [`CONTEXT`] while that UI has
//! it and pops it after, and while it is pushed the arrows, WASD, Tab, Enter,
//! Space and Escape — and the pad's left stick, d-pad, shoulders, South and
//! East — are the UI's and not the game's beneath: the context stack is the
//! disambiguator, not a list of special cases.
//!
//! **The stick reads through [`STICK_DEADZONE`]**, so a stick resting off
//! centre does not walk a list. **The d-pad is a [`Binding::PadDpad`]**, the
//! pad's [`Binding::Wasd`], so it shares one unit disc with the stick and the
//! keys.
//!
//! **Start is not in the table.** Like Escape's pause, it is the engine loop's,
//! and the loop binds it outside this context.
//!
//! **[`MENU`] has no pad binding.** It opens a context menu, which a pad
//! reaches through no convention every platform shares; and a pad button
//! bound here is one a pushed context takes from the game beneath.

use super::{
    ActionDecl, ActionKind, ActionMap, ActionMapError, Binding, Modifier, PAD_ACTIVITY_THRESHOLD,
    PadButton, Repeat, Stick,
};
use crcbl_core::input::KeyCode;

/// The reserved context's name.
pub const CONTEXT: &str = "ui";
/// Spatial focus movement: an `Axis2`, +Y up.
pub const MOVE: &str = "ui_move";
/// Forward in tree order.
pub const NEXT: &str = "ui_next";
/// Backward in tree order.
pub const PREV: &str = "ui_prev";
/// Fire a button, engage a widget, or commit the engaged one.
pub const ACCEPT: &str = "ui_accept";
/// Cancel the engaged widget, or close the screen.
pub const BACK: &str = "ui_back";
/// Open the focused widget's context menu: the keyboard's menu key, and
/// Shift+F10 for a keyboard without one — Windows' pair.
pub const MENU: &str = "ui_menu";

/// Every reserved action, in the table's order.
pub const ACTIONS: [&str; 6] = [MOVE, NEXT, PREV, ACCEPT, BACK, MENU];

/// The dead zone [`MOVE`]'s left stick reads through: the pad's own activity
/// threshold, so the stick navigates exactly when it would also make the pad
/// the last device.
pub const STICK_DEADZONE: f32 = PAD_ACTIVITY_THRESHOLD;

/// The reserved actions with their default bindings.
fn declarations() -> [ActionDecl; ACTIONS.len()] {
    let button = |name: &str, bindings: Vec<Binding>| ActionDecl {
        name: name.to_owned(),
        kind: ActionKind::Button,
        bindings,
    };
    [
        ActionDecl {
            name: MOVE.to_owned(),
            kind: ActionKind::Axis2,
            bindings: vec![
                Binding::Wasd {
                    up: KeyCode::ArrowUp,
                    down: KeyCode::ArrowDown,
                    left: KeyCode::ArrowLeft,
                    right: KeyCode::ArrowRight,
                },
                Binding::Wasd {
                    up: KeyCode::KeyW,
                    down: KeyCode::KeyS,
                    left: KeyCode::KeyA,
                    right: KeyCode::KeyD,
                },
                Binding::PadStick {
                    stick: Stick::Left,
                    deadzone: STICK_DEADZONE,
                },
                Binding::PadDpad,
            ],
        },
        button(
            NEXT,
            vec![
                Binding::Key(KeyCode::Tab),
                Binding::PadButton(PadButton::RightShoulder),
            ],
        ),
        button(
            PREV,
            vec![
                Binding::Chord {
                    modifier: Modifier::Shift,
                    key: KeyCode::Tab,
                },
                Binding::PadButton(PadButton::LeftShoulder),
            ],
        ),
        button(
            ACCEPT,
            vec![
                Binding::Key(KeyCode::Enter),
                Binding::Key(KeyCode::Space),
                Binding::PadButton(PadButton::South),
            ],
        ),
        button(
            BACK,
            vec![
                Binding::Key(KeyCode::Escape),
                Binding::PadButton(PadButton::East),
            ],
        ),
        button(
            MENU,
            vec![
                Binding::Key(KeyCode::ContextMenu),
                Binding::Chord {
                    modifier: Modifier::Shift,
                    key: KeyCode::F10,
                },
            ],
        ),
    ]
}

/// Declare the reserved `ui` context into `map`, off the stack, with
/// [`Repeat::UI`] on the three navigation actions.
///
/// # Errors
/// [`ActionMapError::DuplicateName`] if `map` already declares any of the
/// reserved names — checked before any is declared, so a refused call leaves
/// the map as it was.
pub fn declare(map: &mut ActionMap) -> Result<(), ActionMapError> {
    let decls = declarations();
    if let Some(taken) = decls.iter().find(|decl| map.bindings(&decl.name).is_some()) {
        return Err(ActionMapError::DuplicateName(taken.name.clone()));
    }
    for decl in decls {
        map.try_declare_in(CONTEXT, decl)?;
    }
    for name in [MOVE, NEXT, PREV] {
        map.set_repeat(name, Some(Repeat::UI))?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Cardinal, GAMEPLAY_CONTEXT};

    const TICK: f32 = 1.0 / 60.0;

    fn declared() -> ActionMap {
        let mut map = ActionMap::new();
        map.declare(ActionDecl {
            name: "walk".to_owned(),
            kind: ActionKind::Axis2,
            bindings: vec![Binding::Wasd {
                up: KeyCode::KeyW,
                down: KeyCode::KeyS,
                left: KeyCode::KeyA,
                right: KeyCode::KeyD,
            }],
        });
        map.declare(ActionDecl {
            name: "jump".to_owned(),
            kind: ActionKind::Button,
            bindings: vec![Binding::Key(KeyCode::Space)],
        });
        declare(&mut map).expect("nothing reserved is taken");
        map
    }

    /// Declared off the stack, in its own context, and inert until pushed:
    /// the game keeps WASD and Space.
    #[test]
    fn the_ui_context_is_declared_off_the_stack() {
        let mut map = declared();
        for name in ACTIONS {
            assert_eq!(map.context_of(name), Some(CONTEXT), "{name}");
        }
        assert_eq!(
            map.active_contexts().collect::<Vec<_>>(),
            [GAMEPLAY_CONTEXT]
        );
        map.key_event(KeyCode::KeyW, true);
        map.key_event(KeyCode::Space, true);
        assert_eq!(map.axis2("walk"), (0.0, 1.0));
        assert!(map.button_held("jump"));
        assert_eq!(map.cardinal(MOVE), None);
        assert!(!map.button_held(ACCEPT));
    }

    /// **Pushed, it takes the table's keys from the game**, both movement
    /// sets drive one `ui_move`, and Tab and Shift+Tab are distinct.
    #[test]
    fn pushed_it_drives_the_reserved_actions_from_the_table() {
        let mut map = declared();
        map.push_context(CONTEXT).expect("declared");

        map.begin_tick(TICK);
        map.key_event(KeyCode::ArrowLeft, true);
        assert_eq!(map.cardinal(MOVE), Some(Cardinal::Left));
        map.key_event(KeyCode::ArrowLeft, false);
        map.key_event(KeyCode::KeyS, true);
        assert_eq!(map.cardinal(MOVE), Some(Cardinal::Down));
        assert_eq!(map.axis2("walk"), (0.0, 0.0), "S is the UI's now");
        map.key_event(KeyCode::KeyS, false);

        for key in [KeyCode::Enter, KeyCode::Space] {
            map.begin_tick(TICK);
            map.key_event(key, true);
            assert!(map.just_pressed(ACCEPT), "{key}");
            assert!(!map.button_held("jump"));
            map.key_event(key, false);
        }

        map.begin_tick(TICK);
        map.key_event(KeyCode::Escape, true);
        assert!(map.just_pressed(BACK));

        map.begin_tick(TICK);
        map.key_event(KeyCode::Tab, true);
        assert!(map.repeated(NEXT) && !map.button_held(PREV));
        map.key_event(KeyCode::Tab, false);
        map.begin_tick(TICK);
        map.key_event(KeyCode::ShiftRight, true);
        map.key_event(KeyCode::Tab, true);
        assert!(map.repeated(PREV), "Shift+Tab is ui_prev");
        assert!(!map.button_held(NEXT), "and not ui_next as well");
        map.key_event(KeyCode::Tab, false);

        map.begin_tick(TICK);
        map.key_event(KeyCode::F10, true);
        assert!(map.just_pressed(MENU), "Shift+F10 is ui_menu");
        map.key_event(KeyCode::F10, false);
        map.key_event(KeyCode::ShiftRight, false);
        map.begin_tick(TICK);
        map.key_event(KeyCode::F10, true);
        assert!(!map.button_held(MENU), "F10 alone is not");
        map.key_event(KeyCode::F10, false);
        map.begin_tick(TICK);
        map.key_event(KeyCode::ContextMenu, true);
        assert!(map.just_pressed(MENU), "the menu key is ui_menu");
    }

    /// **Pushed, it takes the table's pad column too**: the left stick past
    /// the dead zone moves, drift inside it does not, each d-pad direction
    /// steps its way, the shoulders step through tree order, South accepts and
    /// East backs out.
    #[test]
    fn pushed_it_drives_the_reserved_actions_from_a_pad() {
        use crate::{GamepadEvent, GamepadId, GamepadSnapshot, PadAxis, PadKind};
        let pad = GamepadId(3);
        let at_rest = GamepadSnapshot::neutral(PadKind::Xbox);
        let state = |snapshot| GamepadEvent::State { id: pad, snapshot };
        let holding = |button: PadButton| GamepadSnapshot {
            buttons: [button].into_iter().collect(),
            ..at_rest
        };
        let mut map = declared();
        map.push_context(CONTEXT).expect("declared");

        map.begin_tick(TICK);
        let mut drift = at_rest;
        drift.axes[PadAxis::LeftY as usize] = -STICK_DEADZONE / 2.0;
        map.gamepad_event(&state(drift));
        assert_eq!(map.cardinal(MOVE), None, "drift inside the dead zone");
        let mut down = at_rest;
        down.axes[PadAxis::LeftY as usize] = -1.0;
        map.gamepad_event(&state(down));
        assert_eq!(map.cardinal(MOVE), Some(Cardinal::Down));
        assert_eq!(map.axis2("walk"), (0.0, 0.0), "the game binds no stick");
        map.gamepad_event(&state(at_rest));

        for (button, direction) in [
            (PadButton::DpadUp, Cardinal::Up),
            (PadButton::DpadDown, Cardinal::Down),
            (PadButton::DpadLeft, Cardinal::Left),
            (PadButton::DpadRight, Cardinal::Right),
        ] {
            map.begin_tick(TICK);
            map.gamepad_event(&state(holding(button)));
            assert_eq!(map.cardinal(MOVE), Some(direction), "{button:?}");
            assert!(map.repeated(MOVE), "{button:?} steps once on the press");
            map.gamepad_event(&state(at_rest));
        }

        for (button, action) in [
            (PadButton::RightShoulder, NEXT),
            (PadButton::LeftShoulder, PREV),
            (PadButton::South, ACCEPT),
            (PadButton::East, BACK),
        ] {
            map.begin_tick(TICK);
            map.gamepad_event(&state(holding(button)));
            assert!(map.just_pressed(action), "{button:?} is {action}");
            map.gamepad_event(&state(at_rest));
        }
    }

    /// The reserved actions rebind like any other.
    #[test]
    fn the_reserved_actions_rebind() {
        let mut map = declared();
        map.push_context(CONTEXT).expect("declared");
        map.rebind(ACCEPT, vec![Binding::Key(KeyCode::KeyE)])
            .expect("declared");
        map.key_event(KeyCode::KeyE, true);
        map.key_event(KeyCode::Space, true);
        assert!(map.button_held(ACCEPT));
        assert!(
            map.button_held("jump"),
            "Space is no longer the UI's, so it falls through"
        );
    }

    /// A clash is refused whole, before anything is declared.
    #[test]
    fn a_clash_declares_nothing() {
        let mut map = ActionMap::new();
        map.declare(ActionDecl {
            name: BACK.to_owned(),
            kind: ActionKind::Button,
            bindings: vec![],
        });
        assert_eq!(
            declare(&mut map),
            Err(ActionMapError::DuplicateName(BACK.to_owned())),
        );
        assert_eq!(map.action_names().collect::<Vec<_>>(), [BACK]);
    }
}