use crate::vim::{
SearchQuery, VimMode, VimSelectionState, VisualMode, literal_match_ranges_in_range,
};
use std::ops::Range;
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)
})
}
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))
}
}
}
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
}
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')
}
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,
});
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenderedVimTextSpan {
pub text: String,
pub kind: VimTextSpanKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VimTextSpanKind {
Normal,
SearchMatch,
Selection,
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);
}
}
}
}