alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Normal-mode leader key state.

use super::{KeySequence, KeyToken, action::VimAction};

/// Default delay before the leader menu is shown.
pub const DEFAULT_LEADER_DELAY_MS: u64 = 150;

/// Normal-mode leader key state.
#[derive(Clone, Debug, Default, Eq, PartialEq, bevy::prelude::Resource)]
pub struct LeaderState {
    /// Current leader interaction state.
    state: LeaderInteraction,
}

impl LeaderState {
    /// Starts waiting for a leader key sequence.
    pub const fn start(&mut self) {
        self.state = LeaderInteraction::Pending { elapsed_ms: 0 };
    }

    /// Cancels pending or visible leader UI state.
    pub const fn cancel(&mut self) {
        self.state = LeaderInteraction::Inactive;
    }

    /// Returns whether a leader sequence is pending.
    #[must_use]
    pub const fn is_pending(&self) -> bool {
        matches!(self.state, LeaderInteraction::Pending { .. })
    }

    /// Returns whether the leader menu should be rendered.
    #[must_use]
    pub const fn is_menu_visible(&self) -> bool {
        matches!(self.state, LeaderInteraction::MenuVisible)
    }

    /// Advances the pending timer.
    pub const fn tick(&mut self, delta_ms: u64, delay_ms: u64) {
        if let LeaderInteraction::Pending { elapsed_ms } = &mut self.state {
            *elapsed_ms = elapsed_ms.saturating_add(delta_ms);
            if *elapsed_ms >= delay_ms {
                self.state = LeaderInteraction::MenuVisible;
            }
        }
    }
}

/// Internal leader state.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
enum LeaderInteraction {
    /// No active leader interaction.
    #[default]
    Inactive,
    /// Leader was pressed and is waiting for a sequence key.
    Pending {
        /// Milliseconds elapsed since leader was pressed.
        elapsed_ms: u64,
    },
    /// The leader menu is visible.
    MenuVisible,
}

/// A typed leader key binding.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LeaderBinding {
    /// Key sequence after the leader key.
    pub sequence: KeySequence,
    /// Short label shown in the menu.
    pub label: String,
    /// Action executed when the sequence is typed.
    pub action: VimAction,
}

impl LeaderBinding {
    /// Creates a leader binding.
    #[must_use]
    pub fn new(sequence: KeySequence, label: impl Into<String>, action: VimAction) -> Self {
        Self {
            sequence,
            label: label.into(),
            action,
        }
    }
}

/// Configurable leader behavior.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LeaderConfig {
    /// Leader key.
    pub key: KeyToken,
    /// Delay before showing the menu.
    pub delay_ms: u64,
    /// Default bindings.
    pub bindings: Vec<LeaderBinding>,
}

impl LeaderConfig {
    /// Finds a binding for `sequence`.
    #[must_use]
    pub fn binding_for(&self, sequence: &[KeyToken]) -> Option<&LeaderBinding> {
        self.bindings
            .iter()
            .find(|binding| binding.sequence.as_slice() == sequence)
    }
}

impl Default for LeaderConfig {
    fn default() -> Self {
        use super::{PageDirection, SearchDirection};

        Self {
            key: KeyToken::Char(' '),
            delay_ms: DEFAULT_LEADER_DELAY_MS,
            bindings: vec![
                LeaderBinding::new(
                    KeySequence::new([KeyToken::Char('w')]),
                    "w write",
                    VimAction::ExCommand(String::from("w")),
                ),
                LeaderBinding::new(
                    KeySequence::new([KeyToken::Char('q')]),
                    "q quit",
                    VimAction::ExCommand(String::from("q")),
                ),
                LeaderBinding::new(
                    KeySequence::new([KeyToken::Char('x')]),
                    "x write-quit",
                    VimAction::ExCommand(String::from("wq")),
                ),
                LeaderBinding::new(
                    KeySequence::new([KeyToken::Char('n')]),
                    "n next search",
                    VimAction::RepeatSearch(SearchDirection::Forward),
                ),
                LeaderBinding::new(
                    KeySequence::new([KeyToken::Char('N')]),
                    "N previous search",
                    VimAction::RepeatSearch(SearchDirection::Backward),
                ),
                LeaderBinding::new(
                    KeySequence::new([KeyToken::Char('f')]),
                    "f page down",
                    VimAction::ViewportPage(PageDirection::Forward),
                ),
                LeaderBinding::new(
                    KeySequence::new([KeyToken::Char('b')]),
                    "b page up",
                    VimAction::ViewportPage(PageDirection::Backward),
                ),
                LeaderBinding::new(
                    KeySequence::new([KeyToken::Char('h')]),
                    "h help",
                    VimAction::NoOp,
                ),
            ],
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{DEFAULT_LEADER_DELAY_MS, LeaderState};

    #[test]
    fn leader_menu_appears_after_delay() {
        let mut state = LeaderState::default();

        state.start();
        assert!(state.is_pending());
        state.tick(DEFAULT_LEADER_DELAY_MS - 1, DEFAULT_LEADER_DELAY_MS);
        assert!(!state.is_menu_visible());
        state.tick(1, DEFAULT_LEADER_DELAY_MS);
        assert!(state.is_menu_visible());
    }

    #[test]
    fn leader_cancel_clears_pending_or_visible_state() {
        let mut state = LeaderState::default();

        state.start();
        state.cancel();
        assert!(!state.is_pending());

        state.start();
        state.tick(DEFAULT_LEADER_DELAY_MS, DEFAULT_LEADER_DELAY_MS);
        state.cancel();
        assert!(!state.is_menu_visible());
    }
}