lesser 0.1.0

A lesser pager (even less than less), for everyday use
use unicode_width::UnicodeWidthChar;

use crate::buffer::LineBuffer;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Segment {
    pub line_idx: usize,
    pub byte_start: usize,
    pub byte_end: usize,
}

pub struct Layout {
    segments: Vec<Segment>,
    width: u16,
    chop: bool,
}

impl Layout {
    pub fn build(buf: &LineBuffer, width: u16, chop: bool) -> Self {
        let mut segments = Vec::with_capacity(buf.lines().len());
        for (line_idx, line) in buf.lines().iter().enumerate() {
            split_line(line, line_idx, width, chop, &mut segments);
        }
        Self { segments, width, chop }
    }

    pub fn rebuild(&mut self, buf: &LineBuffer, width: u16, chop: bool) {
        if self.width == width && self.chop == chop {
            return;
        }
        *self = Self::build(buf, width, chop);
    }

    pub fn len(&self) -> usize {
        self.segments.len()
    }

    #[allow(dead_code)]
    pub fn is_empty(&self) -> bool {
        self.segments.is_empty()
    }

    pub fn segment(&self, idx: usize) -> Option<&Segment> {
        self.segments.get(idx)
    }

    /// First visual-row index whose segment maps to logical line `line_idx`.
    pub fn first_segment_of(&self, line_idx: usize) -> Option<usize> {
        self.segments.iter().position(|s| s.line_idx == line_idx)
    }
}

fn split_line(line: &str, line_idx: usize, width: u16, chop: bool, out: &mut Vec<Segment>) {
    let max_cols = width.max(1) as usize;
    let mut cols = 0usize;
    let mut seg_start = 0usize;
    let mut chars = line.char_indices().peekable();

    while let Some((i, c)) = chars.next() {
        if c == '\x1b' {
            if let Some(&(_, '[')) = chars.peek() {
                chars.next();
                while let Some(&(_, p)) = chars.peek() {
                    let v = p as u32;
                    if (0x20..=0x3F).contains(&v) {
                        chars.next();
                    } else {
                        break;
                    }
                }
                if let Some(&(_, p)) = chars.peek() {
                    let v = p as u32;
                    if (0x40..=0x7E).contains(&v) {
                        chars.next();
                    }
                }
                continue;
            }
        }
        let w = c.width().unwrap_or(0);
        if cols + w > max_cols {
            out.push(Segment { line_idx, byte_start: seg_start, byte_end: i });
            if chop {
                return;
            }
            seg_start = i;
            cols = w;
        } else {
            cols += w;
        }
    }
    out.push(Segment {
        line_idx,
        byte_start: seg_start,
        byte_end: line.len(),
    });
}

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

    fn buf_from(input: &[u8]) -> LineBuffer {
        let mut buf = LineBuffer::new(Cursor::new(input.to_vec()));
        buf.fill_all().unwrap();
        buf
    }

    #[test]
    fn chop_truncates_long_line() {
        let buf = buf_from(b"short\nthis is too long\n");
        let layout = Layout::build(&buf, 5, true);
        assert_eq!(layout.len(), 2);
        let s1 = layout.segment(1).unwrap();
        assert_eq!(s1.line_idx, 1);
        assert_eq!(s1.byte_start, 0);
        assert_eq!(s1.byte_end, 5);
    }

    #[test]
    fn wrap_creates_multiple_segments() {
        let buf = buf_from(b"0123456789\n");
        let layout = Layout::build(&buf, 4, false);
        assert_eq!(layout.len(), 3);
        assert_eq!(layout.segment(0).unwrap().byte_end, 4);
        assert_eq!(layout.segment(1).unwrap().byte_start, 4);
        assert_eq!(layout.segment(1).unwrap().byte_end, 8);
        assert_eq!(layout.segment(2).unwrap().byte_start, 8);
        assert_eq!(layout.segment(2).unwrap().byte_end, 10);
    }

    #[test]
    fn empty_line_yields_one_segment() {
        let buf = buf_from(b"\nfoo\n");
        let layout = Layout::build(&buf, 80, false);
        assert_eq!(layout.len(), 2);
        let s0 = layout.segment(0).unwrap();
        assert_eq!(s0.byte_start, 0);
        assert_eq!(s0.byte_end, 0);
    }

    #[test]
    fn sgr_doesnt_count_toward_width() {
        // "\x1b[31mhello\x1b[0m" — visible width 5
        let buf = buf_from(b"\x1b[31mhello\x1b[0m\n");
        let layout = Layout::build(&buf, 5, false);
        assert_eq!(layout.len(), 1);
    }

    #[test]
    fn first_segment_of_logical_line() {
        let buf = buf_from(b"a\nlong line one two three\n");
        let layout = Layout::build(&buf, 5, false);
        // segment 0 = line 0; segments 1..N = line 1
        assert_eq!(layout.first_segment_of(0), Some(0));
        assert_eq!(layout.first_segment_of(1), Some(1));
    }

    #[test]
    fn rebuild_noop_when_unchanged() {
        let buf = buf_from(b"abc\n");
        let mut layout = Layout::build(&buf, 80, false);
        let n = layout.len();
        layout.rebuild(&buf, 80, false);
        assert_eq!(layout.len(), n);
    }

    #[test]
    fn rebuild_recomputes_on_width_change() {
        let buf = buf_from(b"0123456789\n");
        let mut layout = Layout::build(&buf, 80, false);
        assert_eq!(layout.len(), 1);
        layout.rebuild(&buf, 5, false);
        assert_eq!(layout.len(), 2);
    }

    #[test]
    fn rebuild_recomputes_on_chop_change() {
        let buf = buf_from(b"0123456789\n");
        let mut layout = Layout::build(&buf, 4, false);
        assert_eq!(layout.len(), 3);
        layout.rebuild(&buf, 4, true);
        assert_eq!(layout.len(), 1);
    }

    #[test]
    fn wide_chars_count_as_two_cols() {
        // 你好 = 2 wide chars, 4 cols total. Width 3 → wrap after first.
        let buf = buf_from("你好\n".as_bytes());
        let layout = Layout::build(&buf, 3, false);
        assert_eq!(layout.len(), 2);
    }
}