alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Selection primitives shared by visual-mode commands and rendering.

use std::ops::Range;

use bevy::prelude::Resource;

use super::motion::{clamp_to_boundary, clamp_to_cursor_position};

/// Current visual selection anchor, if visual mode is active.
#[derive(Clone, Debug, Default, Eq, PartialEq, Resource)]
pub struct VimSelectionState {
    /// Active selection anchor.
    selection: Option<VimSelection>,
}

impl VimSelectionState {
    /// Starts a visual selection at `anchor_byte_index`.
    pub fn start(&mut self, text: &str, anchor_byte_index: usize) {
        self.selection = Some(VimSelection::new(text, anchor_byte_index));
    }

    /// Clears the active visual selection.
    pub const fn clear(&mut self) {
        self.selection = None;
    }

    /// Reads the active visual selection.
    #[must_use]
    pub const fn selection(&self) -> Option<VimSelection> {
        self.selection
    }

    /// Moves the active anchor to `anchor_byte_index`.
    pub fn set_anchor(&mut self, text: &str, anchor_byte_index: usize) {
        if self.selection.is_some() {
            self.selection = Some(VimSelection::new(text, anchor_byte_index));
        }
    }
}

/// Character-wise visual selection anchored at a byte index.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VimSelection {
    /// Anchor byte index, always clamped to a UTF-8 character boundary for its source text.
    anchor_byte_index: usize,
}

impl VimSelection {
    /// Creates a selection anchor for `text`.
    #[must_use]
    pub fn new(text: &str, anchor_byte_index: usize) -> Self {
        Self {
            anchor_byte_index: clamp_to_boundary(text, anchor_byte_index),
        }
    }

    /// Reads the selection anchor byte index.
    #[must_use]
    pub const fn anchor_byte_index(&self) -> usize {
        self.anchor_byte_index
    }

    /// Returns the ordered half-open byte range between the anchor and cursor.
    #[must_use]
    pub fn byte_range(self, text: &str, cursor_byte_index: usize) -> Range<usize> {
        let anchor = clamp_to_boundary(text, self.anchor_byte_index);
        let cursor = clamp_to_boundary(text, cursor_byte_index);

        anchor.min(cursor)..anchor.max(cursor)
    }

    /// Returns the ordered character-wise visual range, including the cursor cell.
    #[must_use]
    pub fn characterwise_byte_range(self, text: &str, cursor_byte_index: usize) -> Range<usize> {
        let anchor = clamp_to_cursor_position(text, self.anchor_byte_index);
        let cursor = clamp_to_cursor_position(text, cursor_byte_index);
        let start = anchor.min(cursor);
        let end_cell = anchor.max(cursor);
        let end = text[end_cell..]
            .chars()
            .next()
            .map_or(end_cell, |character| end_cell + character.len_utf8());

        start..end
    }

    /// Returns the ordered line-wise visual range covering every touched line.
    #[must_use]
    pub fn linewise_byte_range(self, text: &str, cursor_byte_index: usize) -> Range<usize> {
        let anchor = clamp_to_boundary(text, self.anchor_byte_index);
        let cursor = clamp_to_boundary(text, cursor_byte_index);
        let start = anchor.min(cursor);
        let end = anchor.max(cursor);

        line_start(text, start)..line_content_end(text, end)
    }
}

/// Returns the byte index at the start of the line containing `index`.
fn line_start(text: &str, index: usize) -> usize {
    let index = clamp_to_boundary(text, index);

    text[..index]
        .rfind('\n')
        .map_or(0, |newline_index| newline_index + '\n'.len_utf8())
}

/// Returns the byte index after the visible content on the line containing `index`.
fn line_content_end(text: &str, index: usize) -> usize {
    let index = clamp_to_boundary(text, index);

    text[index..]
        .find('\n')
        .map_or(text.len(), |newline_offset| index + newline_offset)
}

#[cfg(test)]
mod tests {
    use super::VimSelection;
    use proptest::prelude::*;

    #[test]
    fn selection_range_orders_anchor_and_cursor() {
        let text = "AλBC";
        let selection = VimSelection::new(text, "AλB".len());

        assert_eq!(selection.byte_range(text, 1), 1.."AλB".len());
    }

    #[test]
    fn characterwise_selection_includes_cursor_cell() {
        let text = "ALMA";
        let selection = VimSelection::new(text, 1);

        assert_eq!(selection.characterwise_byte_range(text, 2), 1..3);
    }

    #[test]
    fn linewise_selection_covers_touched_line_content() {
        let text = "one\ntwo\nthree";
        let selection = VimSelection::new(text, "one\nt".len());

        assert_eq!(
            selection.linewise_byte_range(text, "one\ntwo\nth".len()),
            4..13
        );
    }

    proptest! {
        #[test]
        fn selection_ranges_stay_on_utf8_boundaries(
            text in "\\PC*",
            anchor in any::<usize>(),
            cursor in any::<usize>(),
        ) {
            let selection = VimSelection::new(&text, anchor);
            let range = selection.byte_range(&text, cursor);

            prop_assert!(range.start <= range.end);
            prop_assert!(range.end <= text.len());
            prop_assert!(text.is_char_boundary(range.start));
            prop_assert!(text.is_char_boundary(range.end));
        }
    }
}