txtview 0.1.0

A lightweight terminal text viewer with scrolling, wrapping, line numbers, and an optional interactive scrollbar
Documentation
use std::io;

use crossterm::{
    cursor::MoveTo,
    queue,
    terminal::{self, Clear, ClearType},
};

use super::TxtView;

impl TxtView {
    pub(super) fn draw(&mut self, stdout: &mut impl io::Write) -> io::Result<()> {
        let rows = terminal::size()?.1;
        self.refresh_bounds();

        let visible = self.visible_rows() as usize;

        self.render_rows(stdout, 0..visible)?;
        self.render_scrollbar(stdout, visible)?;

        self.render_help_bar(stdout, rows)?;

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

    fn render_rows(
        &mut self,
        stdout: &mut impl io::Write,
        range: std::ops::Range<usize>,
    ) -> io::Result<()> {
        for i in range {
            let text = self
                .display
                .get(self.offset + i)
                .map(String::as_str)
                .unwrap_or("");
            queue!(stdout, MoveTo(0, i as u16), Clear(ClearType::CurrentLine))?;
            write!(stdout, "{}", text)?;
        }
        Ok(())
    }

    fn render_scrollbar(&mut self, stdout: &mut impl io::Write, visible: usize) -> io::Result<()> {
        let Some(g) = self.scroll_geometry(visible) else {
            return Ok(());
        };
        for i in 0..g.visible {
            let ch = if i >= g.top && i < g.top + g.size {
                if self.dragging { '▓' } else { '█' }
            } else {
                '░'
            };
            queue!(stdout, MoveTo(g.column, i as u16))?;
            write!(stdout, "{}", ch)?;
        }
        Ok(())
    }

    fn render_help_bar(&mut self, stdout: &mut impl io::Write, rows: u16) -> io::Result<()> {
        if !self.config.show_help_bar {
            return Ok(());
        }

        let cols = self.help_wrap_cols();
        let lines = wrap_lines(&self.help_text(), cols);
        let reserve = 1 + lines.len();
        if (rows as usize) < reserve {
            return Ok(());
        }
        let base = rows as usize - reserve;

        queue!(
            stdout,
            MoveTo(0, base as u16),
            Clear(ClearType::CurrentLine)
        )?;
        write!(stdout, "{}", "─".repeat(cols))?;

        for (i, line) in lines.iter().enumerate() {
            queue!(
                stdout,
                MoveTo(0, (base + 1 + i) as u16),
                Clear(ClearType::CurrentLine)
            )?;
            write!(stdout, "{}", line)?;
        }

        Ok(())
    }
}

fn wrap_lines(s: &str, width: usize) -> Vec<String> {
    let width = width.max(1);
    let chars: Vec<char> = s.chars().collect();
    if chars.is_empty() {
        return vec![String::new()];
    }
    chars
        .chunks(width)
        .map(|chunk| chunk.iter().collect())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::super::TxtView;
    use crate::TxtViewConfig;

    fn viewer(n: usize) -> TxtView {
        let text = (0..n)
            .map(|i| format!("line {i}"))
            .collect::<Vec<_>>()
            .join("\n");
        let config = TxtViewConfig {
            viewport_height: Some(10),
            viewport_width: Some(80),
            show_help_bar: false,
            ..TxtViewConfig::default()
        };
        TxtView::new(&text).with_config(config)
    }

    #[test]
    fn scrollbar_emits_thumb_blocks() {
        let mut v = viewer(25);
        assert!(v.max_offset > 0);
        let mut out = Vec::new();
        v.render_scrollbar(&mut out, v.visible_rows() as usize)
            .unwrap();
        let s = String::from_utf8(out).unwrap();
        assert!(s.contains('█'), "expected a thumb in output: {s:?}");
    }

    #[test]
    fn scrollbar_skipped_when_everything_fits() {
        let mut v = viewer(5);
        assert_eq!(v.max_offset, 0);
        let mut out = Vec::new();
        v.render_scrollbar(&mut out, v.visible_rows() as usize)
            .unwrap();
        assert!(out.is_empty(), "expected no output: {out:?}");
    }

    #[test]
    fn offset_from_thumb_top_is_monotonic_and_bounded() {
        let v = viewer(100);
        let visible = v.visible_rows() as usize;
        let g = v.scroll_geometry(visible).expect("scrollbar present");
        let travel = (g.visible - g.size) as i64;

        assert_eq!(v.offset_from_thumb_top(-5, &g), 0);
        assert_eq!(v.offset_from_thumb_top(0, &g), 0);
        assert_eq!(v.offset_from_thumb_top(travel, &g), v.max_offset);

        let mut prev = 0;
        for top in 0..=travel {
            let off = v.offset_from_thumb_top(top, &g);
            assert!(off >= prev, "not monotonic at top={top}");
            assert!(off <= v.max_offset, "exceeds max_offset at top={top}");
            prev = off;
        }
    }

    #[test]
    fn dragging_keeps_thumb_on_mouse() {
        let mut v = viewer(100);
        let visible = v.visible_rows() as usize;
        let g = v.scroll_geometry(visible).unwrap();

        for mouse_y in 0..visible as u16 {
            let off = v.offset_from_thumb_top(mouse_y as i64, &g);
            v.offset = off;
            let moved = v.scroll_geometry(visible).unwrap();
            let desired = (mouse_y as i64).clamp(0, (g.visible - g.size) as i64) as usize;
            assert!(
                moved.top.abs_diff(desired) <= 1,
                "thumb at {} dragged to {} for y={}",
                moved.top,
                desired,
                mouse_y
            );
        }
    }
}