lesser 0.1.0

A lesser pager (even less than less), for everyday use
use std::io::{self, Write};
use std::ops::Range;

use crossterm::{cursor, queue, style::Print, terminal};
use regex::Regex;

use crate::buffer::LineBuffer;
use crate::flags::Flags;
use crate::layout::Layout;

pub struct Viewport {
    pub top: usize,
    pub rows: u16,
    pub cols: u16,
}

impl Viewport {
    pub fn content_rows(&self) -> usize {
        (self.rows as usize).saturating_sub(1)
    }
}

const SGR_RESET: &str = "\x1b[0m";
const HL_ON: &str = "\x1b[7m";
const HL_OFF: &str = "\x1b[27m";

pub fn draw(
    buf: &LineBuffer,
    layout: &Layout,
    vp: &Viewport,
    _flags: &Flags,
    status: &str,
    highlight: Option<&Regex>,
) -> anyhow::Result<()> {
    let mut out = io::stdout().lock();
    queue!(
        out,
        terminal::Clear(terminal::ClearType::All),
        cursor::MoveTo(0, 0),
        Print(SGR_RESET),
    )?;

    let content_rows = vp.content_rows();

    for i in 0..content_rows {
        let seg_idx = vp.top + i;
        let Some(seg) = layout.segment(seg_idx) else { break };
        let line = &buf.lines()[seg.line_idx];

        let highlights: Vec<Range<usize>> = match highlight {
            Some(re) => re.find_iter(line).map(|m| m.range()).collect(),
            None => Vec::new(),
        };

        let rendered = render_segment(line, seg.byte_start..seg.byte_end, &highlights);
        queue!(out, cursor::MoveTo(0, i as u16), Print(rendered))?;

        let next_is_same_line = layout
            .segment(seg_idx + 1)
            .is_some_and(|n| n.line_idx == seg.line_idx);
        if !next_is_same_line {
            queue!(out, Print(SGR_RESET))?;
        }
    }

    queue!(
        out,
        cursor::MoveTo(0, content_rows as u16),
        Print(SGR_RESET),
        terminal::Clear(terminal::ClearType::CurrentLine),
        Print(status),
    )?;

    out.flush()?;
    Ok(())
}

fn render_segment(line: &str, range: Range<usize>, highlights: &[Range<usize>]) -> String {
    let s = &line[range.start..range.end];
    let mut out = String::with_capacity(s.len() + 16);
    let mut in_hl = false;
    let mut chars = s.char_indices().peekable();

    while let Some((local_i, c)) = chars.next() {
        let abs_i = range.start + local_i;
        let want_hl = highlights.iter().any(|r| r.contains(&abs_i));
        if want_hl && !in_hl {
            out.push_str(HL_ON);
            in_hl = true;
        } else if !want_hl && in_hl {
            out.push_str(HL_OFF);
            in_hl = false;
        }

        if c == '\x1b' {
            if let Some(&(_, '[')) = chars.peek() {
                out.push(c);
                let (_, br) = chars.next().unwrap();
                out.push(br);
                while let Some(&(_, p)) = chars.peek() {
                    let v = p as u32;
                    if (0x20..=0x3F).contains(&v) {
                        let (_, p) = chars.next().unwrap();
                        out.push(p);
                    } else {
                        break;
                    }
                }
                if let Some(&(_, p)) = chars.peek() {
                    let v = p as u32;
                    if (0x40..=0x7E).contains(&v) {
                        let (_, p) = chars.next().unwrap();
                        out.push(p);
                    }
                }
                continue;
            }
        }

        out.push(c);
    }

    if in_hl {
        out.push_str(HL_OFF);
    }
    out
}

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

    #[test]
    fn renders_full_segment_unchanged() {
        let s = "hello";
        assert_eq!(render_segment(s, 0..5, &[]), "hello");
    }

    #[test]
    fn renders_partial_segment() {
        let s = "0123456789";
        assert_eq!(render_segment(s, 0..5, &[]), "01234");
        assert_eq!(render_segment(s, 5..10, &[]), "56789");
    }

    #[test]
    fn highlights_match_with_sgr() {
        let s = "hello";
        let r = render_segment(s, 0..5, &[2..4]);
        assert_eq!(r, format!("he{}ll{}o", HL_ON, HL_OFF));
    }

    #[test]
    fn highlight_uses_absolute_byte_offsets() {
        // Highlight range applies to original line. When segment is 5..10,
        // a highlight at 6..8 covers chars '6' and '7'.
        let s = "0123456789";
        let r = render_segment(s, 5..10, &[6..8]);
        assert_eq!(r, format!("5{}67{}89", HL_ON, HL_OFF));
    }

    #[test]
    fn sgr_passes_through_in_segment() {
        let s = "\x1b[31mhi\x1b[0m";
        assert_eq!(render_segment(s, 0..s.len(), &[]), s);
    }

    #[test]
    fn highlight_closed_at_segment_end() {
        let s = "hello";
        let r = render_segment(s, 0..5, &[3..10]);
        assert!(r.ends_with(HL_OFF));
    }
}