alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Pure rendered-text span derivation for Vim editor views.

use crate::vim::{
    SearchQuery, VimMode, VimSelectionState, VisualMode, literal_match_ranges_in_range,
};
use std::ops::Range;

/// Returns search match ranges for the last submitted query.
pub fn active_search_match_ranges(
    text: &str,
    query: Option<&SearchQuery>,
    visible_range: Range<usize>,
) -> Vec<Range<usize>> {
    query.map_or_else(Vec::new, |query| {
        literal_match_ranges_in_range(text, query, visible_range)
    })
}

/// Returns the active visual selection range for the current mode.
pub fn active_selection_range(
    text: &str,
    mode: VimMode,
    selection_state: &VimSelectionState,
    cursor_byte_index: usize,
) -> Option<Range<usize>> {
    let selection = selection_state.selection()?;

    match mode {
        VimMode::Normal => None,
        VimMode::Visual(VisualMode::Characterwise) => {
            Some(selection.characterwise_byte_range(text, cursor_byte_index))
        }
        VimMode::Visual(VisualMode::Linewise) => {
            Some(selection.linewise_byte_range(text, cursor_byte_index))
        }
    }
}

/// Builds styled text spans, splitting the cursor character into its own span.
pub fn render_text_spans(
    text: &str,
    source_start_byte_index: usize,
    cursor_byte_index: usize,
    selection_range: Option<Range<usize>>,
    search_ranges: &[Range<usize>],
) -> Vec<RenderedVimTextSpan> {
    if text.is_empty() {
        return vec![RenderedVimTextSpan {
            text: String::from(" "),
            kind: VimTextSpanKind::Cursor,
        }];
    }

    let source_end_byte_index = source_start_byte_index + text.len();
    let mut cursor_byte_index =
        cursor_byte_index.clamp(source_start_byte_index, source_end_byte_index);

    while !text.is_char_boundary(cursor_byte_index - source_start_byte_index) {
        cursor_byte_index -= 1;
    }

    let selection_range = selection_range.unwrap_or(0..0);
    let mut spans = Vec::new();

    for (byte_index, character) in text.char_indices() {
        let absolute_byte_index = source_start_byte_index + byte_index;
        let next_byte_index = byte_index + character.len_utf8();
        let selected = selection_range.start <= absolute_byte_index
            && absolute_byte_index < selection_range.end;
        let search_matched = search_ranges
            .iter()
            .any(|range| range.start <= absolute_byte_index && absolute_byte_index < range.end);

        if absolute_byte_index == cursor_byte_index && character == '\n' {
            push_text_span(&mut spans, " ", VimTextSpanKind::Cursor);
            push_text_span(
                &mut spans,
                character.encode_utf8(&mut [0; 4]),
                if selected {
                    VimTextSpanKind::Selection
                } else if search_matched {
                    VimTextSpanKind::SearchMatch
                } else {
                    VimTextSpanKind::Normal
                },
            );
            continue;
        }

        if character == '\n' && is_empty_line_start(text, byte_index) {
            push_text_span(&mut spans, " ", VimTextSpanKind::Normal);
        }

        let kind = if absolute_byte_index == cursor_byte_index {
            VimTextSpanKind::Cursor
        } else if selected {
            VimTextSpanKind::Selection
        } else if search_matched {
            VimTextSpanKind::SearchMatch
        } else {
            VimTextSpanKind::Normal
        };

        push_text_span(&mut spans, &text[byte_index..next_byte_index], kind);
    }

    if cursor_byte_index == source_end_byte_index {
        push_text_span(&mut spans, " ", VimTextSpanKind::Cursor);
    } else if text.ends_with('\n') {
        push_text_span(&mut spans, " ", VimTextSpanKind::Normal);
    }

    spans
}

/// Returns whether `byte_index` is the newline terminating an empty physical line.
fn is_empty_line_start(text: &str, byte_index: usize) -> bool {
    (byte_index == 0 || text[..byte_index].ends_with('\n')) && text[byte_index..].starts_with('\n')
}

/// Appends a non-empty rendered span to the span list.
fn push_text_span(spans: &mut Vec<RenderedVimTextSpan>, text: &str, kind: VimTextSpanKind) {
    if !text.is_empty() {
        if let Some(last_span) = spans.last_mut()
            && last_span.kind == kind
        {
            last_span.text.push_str(text);
            return;
        }

        spans.push(RenderedVimTextSpan {
            text: text.to_owned(),
            kind,
        });
    }
}

/// Text segment with its Vim-specific presentation role.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenderedVimTextSpan {
    /// Span text content.
    pub text: String,
    /// Presentation role for the span.
    pub kind: VimTextSpanKind,
}

/// Presentation role for a rendered text span.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VimTextSpanKind {
    /// Plain text outside the cursor cell.
    Normal,
    /// Text matching the last submitted search.
    SearchMatch,
    /// Text covered by visual mode.
    Selection,
    /// Text currently covered by the cursor cell.
    Cursor,
}

#[cfg(test)]
mod tests {
    use super::{VimTextSpanKind, active_selection_range, render_text_spans};
    use crate::vim::{VimMode, VimSelection, VimSelectionState, VisualMode};
    use proptest::prelude::*;

    #[test]
    fn cursor_highlights_character_at_byte_index() {
        let spans = render_text_spans("ALMA", 0, 1, None, &[]);

        assert_eq!(spans.len(), 3);
        assert_eq!(spans[0].text, "A");
        assert_eq!(spans[1].text, "L");
        assert_eq!(spans[1].kind, VimTextSpanKind::Cursor);
        assert_eq!(spans[2].text, "MA");
    }

    #[test]
    fn cursor_highlights_entire_utf8_character() {
        let spans = render_text_spans("AλB", 0, 1, None, &[]);

        assert_eq!(spans[1].text, "λ");
        assert_eq!(spans[1].kind, VimTextSpanKind::Cursor);
    }

    #[test]
    fn cursor_at_empty_final_line_renders_placeholder() {
        let spans = render_text_spans("Aλ\n", 0, "Aλ\n".len(), None, &[]);

        assert_eq!(spans[0].text, "Aλ\n");
        assert_eq!(spans[1].text, " ");
        assert_eq!(spans[1].kind, VimTextSpanKind::Cursor);
    }

    #[test]
    fn cursor_on_newline_renders_visible_placeholder() {
        let spans = render_text_spans("A\nB", 0, 1, None, &[]);

        assert_eq!(spans[1].text, " ");
        assert_eq!(spans[1].kind, VimTextSpanKind::Cursor);
        assert_eq!(spans[2].text, "\nB");
    }

    #[test]
    fn empty_lines_render_placeholder_cells_even_without_cursor() {
        let spans = render_text_spans("A\n\nB\n", 0, 0, None, &[]);

        assert!(spans.iter().any(|span| span.text.contains("\n \n")));
        assert!(spans.last().is_some_and(|span| span.text.ends_with("\n ")));
    }

    #[test]
    fn visual_selection_highlights_selected_cells() {
        let spans = render_text_spans("ALMA", 0, 2, Some(1..3), &[]);

        assert_eq!(spans[0].text, "A");
        assert_eq!(spans[1].text, "L");
        assert_eq!(spans[1].kind, VimTextSpanKind::Selection);
        assert_eq!(spans[2].text, "M");
        assert_eq!(spans[2].kind, VimTextSpanKind::Cursor);
    }

    #[test]
    fn search_matches_are_highlighted_below_cursor_priority() {
        let spans = render_text_spans("ALMA ALMA", 0, 0, None, &[0..4, 5..9]);

        assert_eq!(spans[0].text, "A");
        assert_eq!(spans[0].kind, VimTextSpanKind::Cursor);
        assert_eq!(spans[1].text, "LMA");
        assert_eq!(spans[1].kind, VimTextSpanKind::SearchMatch);
        assert_eq!(spans[3].text, "ALMA");
        assert_eq!(spans[3].kind, VimTextSpanKind::SearchMatch);
    }

    #[test]
    fn active_selection_range_uses_visual_mode_variant() {
        let text = "one\ntwo";
        let mut selection_state = VimSelectionState::default();
        selection_state.start(text, 1);

        assert_eq!(
            active_selection_range(
                text,
                VimMode::Visual(VisualMode::Characterwise),
                &selection_state,
                2,
            ),
            Some(1..3)
        );

        let linewise_selection = VimSelection::new(text, 1);
        selection_state.start(text, linewise_selection.anchor_byte_index());
        assert_eq!(
            active_selection_range(
                text,
                VimMode::Visual(VisualMode::Linewise),
                &selection_state,
                "one\nt".len(),
            ),
            Some(0..7)
        );
    }

    proptest! {
        #[test]
        fn rendered_spans_preserve_visible_text_order(
            prefix in any::<String>(),
            cursor_cell in any::<String>(),
            suffix in any::<String>(),
        ) {
            let text = format!("{prefix}{cursor_cell}{suffix}");
            let cursor = prefix.len();
            let spans = render_text_spans(&text, 0, cursor, None, &[]);
            let rendered = spans
                .iter()
                .map(|span| span.text.as_str())
                .collect::<String>();

            if text.is_empty() || cursor == text.len() {
                prop_assert_eq!(rendered, format!("{text} "));
            } else if text[cursor..].starts_with('\n') {
                let expected = format!("{prefix} \n{}", &text[cursor + '\n'.len_utf8()..]);
                prop_assert_eq!(rendered, expected);
            } else if text.ends_with('\n') {
                prop_assert_eq!(rendered, format!("{text} "));
            } else {
                prop_assert_eq!(rendered, text);
            }
        }
    }
}