idet-core 0.4.0

Editing logic for text editors, without a frontend
Documentation
//! Breaking long lines into the screen rows that show them, for a frontend
//! that lays out text itself.

/// One screen row: the document line it belongs to, and the range of that
/// line's characters it shows.
///
/// Rows of one line are contiguous and cover it completely, so every column
/// belongs to exactly one row and a caret always has a row to sit on.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Row {
    /// The index of the document line this row is part of.
    pub line: usize,
    /// The first column of the line shown here.
    pub start: usize,
    /// One past the last column shown here.
    pub end: usize,
}

impl Row {
    /// Whether this is the row a line starts on, which is where a frontend
    /// puts the line number.
    #[must_use]
    pub const fn is_line_start(&self) -> bool {
        self.start == 0
    }

    /// How many columns this row shows.
    #[must_use]
    pub const fn width(&self) -> usize {
        self.end - self.start
    }
}

/// Lays out `lines` into the rows that show them, breaking anything wider
/// than `width` at the last space that fits, or mid-word for a word too long
/// to ever fit.
///
/// With `wrap` off every line becomes a single row of its full length, which
/// a frontend then scrolls horizontally.
#[must_use]
pub fn rows<'a>(lines: impl Iterator<Item = &'a str>, width: usize, wrap: bool) -> Vec<Row> {
    let mut rows = Vec::new();
    for (line, text) in lines.enumerate() {
        let length = text.chars().count();
        if !wrap {
            rows.push(Row {
                line,
                start: 0,
                end: length,
            });
            continue;
        }
        let characters: Vec<char> = text.chars().collect();
        let mut start = 0;
        while length - start > width.max(1) {
            let limit = start + width.max(1);
            let end = characters
                .iter()
                .take(limit + 1)
                .skip(start + 1)
                .rposition(|character| *character == ' ')
                .map_or(limit, |index| start + index + 2);
            rows.push(Row { line, start, end });
            start = end;
        }
        rows.push(Row {
            line,
            start,
            end: length,
        });
    }
    rows
}

/// The column just past the last visible character of `row` within `line`.
///
/// A row broken at a space ends *after* that space, and a caret there shows up
/// at the start of the row below rather than at the end of this one. Skipping
/// the trailing spaces gives the end of the row the eye sees, which is where
/// pressing End belongs.
#[must_use]
pub fn visible_end(line: &str, row: Row) -> usize {
    let length = line.chars().count();
    if row.end == length {
        return row.end;
    }
    line.chars()
        .take(row.end)
        .collect::<Vec<char>>()
        .iter()
        .rposition(|character| *character != ' ')
        .map_or(row.start, |index| (index + 1).max(row.start))
}

/// Finds the row holding a caret at `column` of `line`, and how far into that
/// row it sits.
///
/// A caret at a wrap point belongs to the row that follows it, so typing
/// continues where the eye is rather than off the right edge of the row above.
#[must_use]
pub fn locate(rows: &[Row], line: usize, column: usize) -> (usize, usize) {
    let mut last = 0;
    for (index, row) in rows.iter().enumerate() {
        if row.line != line {
            continue;
        }
        last = index;
        if column < row.end {
            return (index, column.saturating_sub(row.start));
        }
    }
    (
        last,
        column.saturating_sub(rows.get(last).map_or(0, |row| row.start)),
    )
}

#[cfg(test)]
mod tests {
    use super::{Row, locate, rows};

    fn layout(text: &str, width: usize) -> Vec<Row> {
        rows(text.split('\n'), width, true)
    }

    #[test]
    fn a_line_that_fits_stays_one_row() {
        assert_eq!(
            layout("hello", 10),
            vec![Row {
                line: 0,
                start: 0,
                end: 5
            }]
        );
    }

    #[test]
    fn a_long_line_breaks_after_a_space() {
        let laid_out = layout("one two three", 8);
        assert_eq!(laid_out.len(), 2);
        assert_eq!(laid_out[0].end, 8);
        assert_eq!(laid_out[1].start, 8);
        assert_eq!(laid_out[1].end, 13);
    }

    #[test]
    fn a_word_too_long_to_fit_breaks_mid_word() {
        let laid_out = layout("abcdefghij", 4);
        assert_eq!(laid_out.len(), 3);
        assert_eq!(
            laid_out[0],
            Row {
                line: 0,
                start: 0,
                end: 4
            }
        );
        assert_eq!(
            laid_out[2],
            Row {
                line: 0,
                start: 8,
                end: 10
            }
        );
    }

    #[test]
    fn rows_of_a_line_leave_no_column_uncovered() {
        for row in layout("a bb ccc dddd eeeee", 6).windows(2) {
            assert_eq!(row[0].end, row[1].start);
        }
    }

    #[test]
    fn every_line_keeps_its_own_rows() {
        let laid_out = layout("short\nlonger than that", 8);
        assert_eq!(laid_out[0].line, 0);
        assert!(laid_out[1..].iter().all(|row| row.line == 1));
    }

    #[test]
    fn without_wrapping_a_line_is_one_row_however_long() {
        assert_eq!(
            rows("a very long line indeed".split('\n'), 4, false).len(),
            1
        );
    }

    #[test]
    fn a_caret_at_a_wrap_point_belongs_to_the_row_below() {
        let laid_out = layout("one two three", 8);
        assert_eq!(locate(&laid_out, 0, 8), (1, 0));
        assert_eq!(locate(&laid_out, 0, 7), (0, 7));
    }

    #[test]
    fn the_visible_end_of_a_broken_row_sits_before_its_trailing_space() {
        let laid_out = layout("one two three", 8);
        assert_eq!(super::visible_end("one two three", laid_out[0]), 7);
        assert_eq!(locate(&laid_out, 0, 7), (0, 7));
    }

    #[test]
    fn the_visible_end_of_a_last_row_is_the_line_end() {
        let laid_out = layout("one two three", 8);
        assert_eq!(super::visible_end("one two three", laid_out[1]), 13);
    }

    #[test]
    fn a_caret_at_the_very_end_stays_on_the_last_row() {
        let laid_out = layout("one two three", 8);
        assert_eq!(locate(&laid_out, 0, 13), (1, 5));
    }
}