alma 0.1.0

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

use super::clamp_to_cursor_position;

/// Moves to the start of the next blank-line-delimited paragraph.
pub(super) fn next_paragraph_start(text: &str, index: usize) -> usize {
    let current_line_start = line_start_at_or_before(text, index);
    let Some(current_paragraph_start) =
        paragraph_start_containing_or_after(text, current_line_start)
    else {
        return clamp_to_cursor_position(text, index);
    };

    for line in lines(text) {
        if line.start <= current_paragraph_start {
            continue;
        }

        if !line.is_blank && starts_paragraph(text, line.start) {
            return line.start;
        }
    }

    clamp_to_cursor_position(text, text.len())
}

/// Moves to the start of the previous blank-line-delimited paragraph.
pub(super) fn previous_paragraph_start(text: &str, index: usize) -> usize {
    let current_line_start = line_start_at_or_before(text, index);
    let current_paragraph_start = paragraph_start_containing_or_before(text, current_line_start)
        .unwrap_or(current_line_start);

    lines(text)
        .filter(|line| line.start < current_paragraph_start)
        .filter(|line| !line.is_blank && starts_paragraph(text, line.start))
        .map(|line| line.start)
        .last()
        .unwrap_or_else(|| clamp_to_cursor_position(text, index))
}

/// Returns the line start at or before `index`.
fn line_start_at_or_before(text: &str, index: usize) -> usize {
    let index = super::clamp_to_boundary(text, index);

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

/// Returns the paragraph start containing `line_start`, or the next paragraph start.
fn paragraph_start_containing_or_after(text: &str, line_start: usize) -> Option<usize> {
    let mut previous_start = None;

    for line in lines(text) {
        if !line.is_blank && starts_paragraph(text, line.start) {
            previous_start = Some(line.start);
        }

        if line.start >= line_start {
            return if line.is_blank {
                lines(text)
                    .filter(|candidate| candidate.start > line.start)
                    .find(|candidate| {
                        !candidate.is_blank && starts_paragraph(text, candidate.start)
                    })
                    .map(|candidate| candidate.start)
            } else {
                previous_start
            };
        }
    }

    previous_start
}

/// Returns the paragraph start containing `line_start`, or the previous paragraph start.
fn paragraph_start_containing_or_before(text: &str, line_start: usize) -> Option<usize> {
    let mut previous_start = None;

    for line in lines(text) {
        if line.start > line_start {
            break;
        }

        if !line.is_blank && starts_paragraph(text, line.start) {
            previous_start = Some(line.start);
        }
    }

    previous_start
}

/// Returns whether the line at `line_start` begins a paragraph.
fn starts_paragraph(text: &str, line_start: usize) -> bool {
    if line_start == 0 {
        return true;
    }

    let previous_line_end = line_start - '\n'.len_utf8();
    let previous_line_start = text[..previous_line_end]
        .rfind('\n')
        .map_or(0, |newline_index| newline_index + '\n'.len_utf8());

    text[previous_line_start..previous_line_end]
        .trim()
        .is_empty()
}

/// Iterates over physical lines without including newline bytes in content ranges.
fn lines(text: &str) -> impl Iterator<Item = ParagraphLine> + '_ {
    let mut start = 0;

    std::iter::from_fn(move || {
        if start > text.len() {
            return None;
        }

        let line_start = start;
        let line_end = text[start..]
            .find('\n')
            .map_or(text.len(), |newline_offset| start + newline_offset);
        start = line_end
            .checked_add('\n'.len_utf8())
            .filter(|next_start| *next_start <= text.len())
            .unwrap_or(text.len() + 1);

        Some(ParagraphLine {
            start: line_start,
            is_blank: text[line_start..line_end].trim().is_empty(),
        })
    })
}

/// Minimal line metadata needed for paragraph scanning.
#[derive(Clone, Copy, Debug)]
struct ParagraphLine {
    /// Byte index at the start of the line.
    start: usize,
    /// Whether line content is empty after trimming Unicode whitespace.
    is_blank: bool,
}