bevy_ineffable 0.2.0

A simple-to-use input manager for bevy that empowers players and makes accessibility easy.
Documentation
//! Handles reports containing lint warnings about `InputConfig`s. Aims to provide detailed, helpful error messages
//! about misconfigured keybinding profiles, and offer concrete suggestions on how to fix the problems.

use std::fmt::{Display, Formatter};

use bevy::log::{error, info, warn};
use serde::{Deserialize, Serialize};

use crate::input_action::InputKind;

/// Generated by scanning an `InputConfig`, contains problems that were found with it.
///
/// Players can create their own `InputConfig` files, which may result in syntactically correct files that deserialise
/// without problems, but that still contain nonsensical data. That is why `bevy_ineffable` provides a validator that
/// generates this report. Game developers can use the report to provide feedback to their players in their GUI.
/// Without such feedback, players will be left wondering why their keybindings just don't work.
///
/// # Examples
///
/// ```
/// # use bevy_ineffable::config::InputConfig;
/// # use bevy_ineffable::prelude::IneffableCommands;
/// pub fn system(mut commands: IneffableCommands) {
///     let config = InputConfig::default();
///     let report = commands.validate(&config);
/// }
/// ```
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub struct InputConfigReport {
    problems: Vec<InputConfigReportItem>,
    // TODO: Conflicts here?
}

impl InputConfigReport {
    /// True iff this report contains no items, ie no problems were discovered.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.problems.is_empty()
    }
    /// Write the report to the log.
    pub fn dump_to_log(&self) {
        for problem in &self.problems {
            match problem.severity {
                Severity::Info => {
                    info!("{}", problem.problem.print());
                }
                Severity::Warning => {
                    warn!("{}", problem.problem.print());
                }
                Severity::Error => {
                    error!("{}", problem.problem.print());
                }
            }
        }
    }
    #[allow(unused)]
    pub(crate) fn info(&mut self, problem: InputConfigProblem) {
        self.problems.push(InputConfigReportItem {
            severity: Severity::Info,
            problem,
        });
    }
    pub(crate) fn warning(&mut self, problem: InputConfigProblem) {
        self.problems.push(InputConfigReportItem {
            severity: Severity::Warning,
            problem,
        });
    }
    pub(crate) fn error(&mut self, problem: InputConfigProblem) {
        self.problems.push(InputConfigReportItem {
            severity: Severity::Error,
            problem,
        });
    }
}

/// Points to a specific keybinding in the config.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub struct ActionLocation {
    /// Which group the `InputAction` belongs to. This is the name of the enum.
    pub group_id: String,
    /// The name of the `InputAction`.
    /// Together with the group_id, this uniquely identifies an `InputAction`.
    /// This is the name of the enum variant.
    pub action_id: String,
    /// Each `InputAction` can have multiple keybindings.
    /// This is the zero-based index of the keybinding in the config.
    pub index: usize,
}

impl Display for ActionLocation {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}::{}#{}", self.group_id, self.action_id, self.index)
    }
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub struct InputConfigReportItem {
    pub severity: Severity,
    pub problem: InputConfigProblem,
}

/// How important is this report item?
#[derive(
    Debug, Default, Serialize, Deserialize, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord,
)]
pub enum Severity {
    /// This is something the user should be made aware of, but which may not be problematic.
    Info,
    /// This indicates an inefficiency: something can be done a better way but is not likely to lead to issues.
    #[default]
    Warning,
    /// Whoever wrote this config probably thought it worked differently.
    /// They are likely to be unpleasantly surprised in-game, when something they thought would work does not.
    /// Alternatively, they may have made an error, perhaps when copy-pasting something.
    /// Either way, they'd want to be notified early.
    Error,
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub enum InputConfigProblem {
    UnknownGroup {
        group_id: String,
        options: Vec<String>,
    },
    UnknownAction {
        group_id: String,
        action_id: String,
        options: Vec<String>,
    },
    ActionWrongKind {
        loc: ActionLocation,
        wrong_kind: InputKind,
        right_kind: InputKind,
    },
    RootBindingIsDummy {
        loc: ActionLocation,
    },
    ConvolutedDummy {
        loc: ActionLocation,
        is_now: String,
    },
    ChordContainsDuplicates {
        loc: ActionLocation,
    },
    SequenceEmpty {
        loc: ActionLocation,
    },
    SequenceOnlyContainsOneElement {
        loc: ActionLocation,
    },
    /// Perhaps the player thought the duration was in seconds, instead of milliseconds?
    SequenceUnrealisticTiming {
        loc: ActionLocation,
        actual_millis: usize,
    },
    // TODO: sequences that contain Dummy and something else.
}

impl InputConfigProblem {
    #[must_use]
    pub fn print(&self) -> String {
        match self {
            InputConfigProblem::UnknownGroup {
                group_id: name,
                options: known,
            } => {
                format!("Unknown group '{name}'. Must be one of: {known:?}.\n\
                \tYou can register this InputAction enum by calling app.register_input_action::<{name}>()")
            }
            InputConfigProblem::UnknownAction {
                group_id,
                action_id,
                options: known,
            } => {
                format!("Unknown action '{group_id}::{action_id}'. Must be one of: {known:?}")
            }
            InputConfigProblem::ActionWrongKind {
                loc,
                wrong_kind,
                right_kind,
            } => {
                format!(
                    "Binding {loc} is of the wrong kind.\n\
                    \tIs a '{wrong_kind:?}': {}\n\
                    \tShould be a '{right_kind:?}': {}\n\
                    \tAn example of a valid {right_kind:?} binding is: {:?}",
                    wrong_kind.explain(),
                    right_kind.explain(),
                    right_kind.example()
                )
            }
            InputConfigProblem::RootBindingIsDummy { loc } => {
                format!("Binding {loc} is a dummy and will never do anything useful. You can safely remove it.")
            }
            InputConfigProblem::ConvolutedDummy { loc, is_now } => {
                format!(
                    "Binding {loc} contains a dummy section that will never activate.\n\
                \t`{is_now}` is redundant can be replaced with `Dummy`."
                )
            }
            InputConfigProblem::ChordContainsDuplicates { loc } => {
                format!("Binding {loc} contains a chord with duplicate entries.\n\
                \tA chord is a group of inputs that must be activated at the same time; for example, Ctrl-S to save a document.\n\
                \tA chord's children should be unique. Duplicates don't do anything and will be ignored.")
            }
            InputConfigProblem::SequenceEmpty { loc } => {
                format!("Binding {loc} contains an empty sequence.\n\
                \tA sequence is a series of inputs that must be triggered one after another, with a maximum delay between individual inputs. For example: entering a cheat code.\n\
                \tAn empty sequence will never activate, and can be replaced with `Dummy` for greater readability.")
            }
            InputConfigProblem::SequenceOnlyContainsOneElement { loc } => {
                format!("Binding {loc} contains a sequence with only one element.\n\
                \tA sequence is a series of inputs that must be triggered one after another, with a maximum delay between individual inputs. For example: entering a cheat code.\n\
                \tA sequence with only one element may be replaced with `WhenPressed(_)` for greater readability.", )
            }
            InputConfigProblem::SequenceUnrealisticTiming { loc, actual_millis } => {
                format!("Binding {loc} contains a sequence with a maximum delay of {actual_millis} milliseconds.\n\
                \tA sequence is a series of inputs that must be triggered one after another, with a maximum delay between individual inputs. For example: entering a cheat code.\n\
                \tThe maximum delay (currently {actual_millis}ms) is the maximum amount of time between any two inputs in the sequence.\n\
                \tThis seems unrealistically low and may never activate. Did you perhaps mean {actual_millis} seconds? If so, change to `{actual_millis}000`.")
            }
        }
    }
}