aven 0.1.12

Local-first task manager CLI and sync server
Documentation
use unicode_width::UnicodeWidthChar;

pub(crate) fn normalize_pasted_newlines(text: &str) -> String {
    text.replace("\r\n", "\n").replace('\r', "\n")
}

pub(crate) fn char_boundary_at_or_before(input: &str, index: usize) -> usize {
    let mut index = index.min(input.len());
    while !input.is_char_boundary(index) {
        index -= 1;
    }
    index
}

pub(crate) fn previous_char_boundary(input: &str, index: usize) -> usize {
    let mut index = char_boundary_at_or_before(input, index).saturating_sub(1);
    while !input.is_char_boundary(index) {
        index -= 1;
    }
    index
}

pub(crate) fn next_char_boundary(input: &str, index: usize) -> usize {
    let mut index = char_boundary_at_or_before(input, index)
        .saturating_add(1)
        .min(input.len());
    while !input.is_char_boundary(index) {
        index += 1;
    }
    index
}

pub(crate) fn previous_word_start(input: &str, index: usize) -> usize {
    let mut index = char_boundary_at_or_before(input, index);
    while index > 0 {
        let previous = previous_char_boundary(input, index);
        if !input[previous..index].chars().all(char::is_whitespace) {
            break;
        }
        index = previous;
    }
    while index > 0 {
        let previous = previous_char_boundary(input, index);
        if input[previous..index].chars().all(char::is_whitespace) {
            break;
        }
        index = previous;
    }
    index
}

pub(crate) fn next_char_is_whitespace(input: &str, index: usize) -> bool {
    input[index..]
        .chars()
        .next()
        .is_some_and(char::is_whitespace)
}

pub(crate) fn cell_width_ranges(line: &str, width: usize) -> Vec<(usize, usize)> {
    if line.is_empty() {
        return vec![(0, 0)];
    }
    let width = width.max(1);
    let mut ranges = Vec::new();
    let mut start = 0;
    let mut count = 0;
    for (index, ch) in line.char_indices() {
        let char_width = ch.width().unwrap_or(0).max(1);
        if count > 0 && count + char_width > width {
            ranges.push((start, index));
            start = index;
            count = 0;
        }
        count += char_width;
    }
    ranges.push((start, line.len()));
    ranges
}

pub(crate) fn char_count_ranges(line: &str, width: usize) -> Vec<(usize, usize)> {
    let width = width.max(1);
    let mut boundaries = line
        .char_indices()
        .map(|(index, _)| index)
        .collect::<Vec<_>>();
    boundaries.push(line.len());
    let char_count = boundaries.len().saturating_sub(1);
    if char_count == 0 {
        return vec![(0, 0)];
    }
    (0..char_count)
        .step_by(width)
        .map(|start| {
            let end = start.saturating_add(width).min(char_count);
            (boundaries[start], boundaries[end])
        })
        .collect()
}

pub(crate) fn char_count_segment_index(line: &str, cursor: usize, width: usize) -> usize {
    let width = width.max(1);
    let cursor = char_boundary_at_or_before(line, cursor);
    let cursor_chars = line[..cursor].chars().count();
    let line_chars = line.chars().count();
    if line_chars == 0 {
        return 0;
    }
    if cursor_chars == line_chars {
        line_chars.saturating_sub(1) / width
    } else {
        cursor_chars / width
    }
}

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

    #[test]
    fn boundaries_snap_to_valid_utf8_indices() {
        let input = "aé中";
        assert_eq!(char_boundary_at_or_before(input, 2), 1);
        assert_eq!(previous_char_boundary(input, input.len()), 3);
        assert_eq!(next_char_boundary(input, 1), 3);
    }

    #[test]
    fn previous_word_start_skips_trailing_whitespace() {
        assert_eq!(previous_word_start("one two  ", 9), 4);
    }

    #[test]
    fn normalizes_crlf_and_cr_newlines() {
        assert_eq!(normalize_pasted_newlines("a\r\nb\rc"), "a\nb\nc");
    }

    #[test]
    fn cell_width_ranges_keep_valid_boundaries() {
        let line = "a中b";
        assert_eq!(cell_width_ranges(line, 2), vec![(0, 1), (1, 4), (4, 5)]);
        for (start, end) in cell_width_ranges(line, 2) {
            assert!(line.is_char_boundary(start));
            assert!(line.is_char_boundary(end));
        }
    }

    #[test]
    fn char_count_ranges_keep_valid_boundaries() {
        let line = "aé中b";
        assert_eq!(char_count_ranges(line, 2), vec![(0, 3), (3, 7)]);
        for (start, end) in char_count_ranges(line, 2) {
            assert!(line.is_char_boundary(start));
            assert!(line.is_char_boundary(end));
        }
    }

    #[test]
    fn char_count_segment_index_matches_end_cursor_behavior() {
        assert_eq!(char_count_segment_index("abcd", 4, 2), 1);
        assert_eq!(char_count_segment_index("abcd", 2, 2), 1);
    }
}