alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Horizontal character-wise motion helpers.

use super::{CharSearch, CharSearchDirection, CharSearchPlacement, clamp_to_boundary};

/// Returns the UTF-8 boundary for the previous character on the same line.
pub(super) fn previous_char_boundary(text: &str, index: usize) -> usize {
    let index = clamp_to_boundary(text, index);

    let previous = text[..index]
        .char_indices()
        .last()
        .map_or(0, |(byte_index, _character)| byte_index);

    if text[previous..index].starts_with('\n') {
        index
    } else {
        previous
    }
}

/// Returns the UTF-8 boundary after the next character on the same line.
pub(super) fn next_char_boundary(text: &str, index: usize) -> usize {
    let index = clamp_to_boundary(text, index);

    let Some(character) = text[index..].chars().next() else {
        return index;
    };

    if character == '\n' {
        return index;
    }

    let next = index + character.len_utf8();

    if next == text.len() || text[next..].starts_with('\n') {
        index
    } else {
        next
    }
}

/// Returns the start of the physical line containing `index`.
pub(super) 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 just before the newline or end of the current line.
pub(super) 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)
}

/// Returns the first non-blank cell on the current line, falling back to line start.
pub(super) fn first_non_blank(text: &str, index: usize) -> usize {
    let start = line_start(text, index);
    let end = line_content_end(text, index);

    text[start..end]
        .char_indices()
        .find_map(|(offset, character)| (!character.is_whitespace()).then_some(start + offset))
        .unwrap_or(start)
}

/// Returns the last visible cursor cell on the current line.
pub(super) fn line_end(text: &str, index: usize) -> usize {
    let start = line_start(text, index);
    let end = line_content_end(text, index);

    text[start..end]
        .char_indices()
        .last()
        .map_or(start, |(offset, _character)| start + offset)
}

/// Returns a one-based character column within the current line.
pub(super) fn screen_column(text: &str, index: usize, column: usize) -> usize {
    let start = line_start(text, index);
    let end = line_content_end(text, index);
    let zero_based_column = column.saturating_sub(1);

    text[start..end]
        .char_indices()
        .nth(zero_based_column)
        .map_or_else(
            || line_end(text, index),
            |(offset, _character)| start + offset,
        )
}

/// Searches for a character on the current line.
pub(super) fn char_search(text: &str, index: usize, search: CharSearch) -> usize {
    let index = clamp_to_boundary(text, index);
    let line_start = text[..index]
        .rfind('\n')
        .map_or(0, |newline_index| newline_index + '\n'.len_utf8());
    let line_end = text[index..]
        .find('\n')
        .map_or(text.len(), |newline_offset| index + newline_offset);

    match search.direction {
        CharSearchDirection::Forward => forward_char_search(text, index, line_end, search),
        CharSearchDirection::Backward => backward_char_search(text, line_start, index, search),
    }
}

/// Applies a forward `f`/`t` style character search on the current line.
fn forward_char_search(text: &str, index: usize, line_end: usize, search: CharSearch) -> usize {
    let after_cursor = text[index..line_end]
        .chars()
        .next()
        .map_or(index, |character| index + character.len_utf8());

    text[after_cursor..line_end]
        .char_indices()
        .find_map(|(offset, character)| {
            (character == search.target).then(|| {
                let match_index = after_cursor + offset;

                match search.placement {
                    CharSearchPlacement::OnMatch => match_index,
                    CharSearchPlacement::BeforeMatch => previous_char_boundary(text, match_index),
                }
            })
        })
        .unwrap_or(index)
}

/// Applies a backward `F`/`T` style character search on the current line.
fn backward_char_search(text: &str, line_start: usize, index: usize, search: CharSearch) -> usize {
    text[line_start..index]
        .char_indices()
        .rev()
        .find_map(|(offset, character)| {
            (character == search.target).then(|| {
                let match_index = line_start + offset;

                match search.placement {
                    CharSearchPlacement::OnMatch => match_index,
                    CharSearchPlacement::BeforeMatch => next_char_boundary(text, match_index),
                }
            })
        })
        .unwrap_or(index)
}