txtview 0.1.0

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

impl TxtView {
    pub(crate) fn scroll_down(&mut self, amount: usize) -> isize {
        self.refresh_bounds();
        let old = self.offset as isize;
        self.offset = self.offset.saturating_add(amount).min(self.max_offset);
        self.offset as isize - old
    }

    pub(crate) fn scroll_to(&mut self, offset: usize) -> isize {
        self.refresh_bounds();
        let old = self.offset as isize;
        self.offset = offset.min(self.max_offset);
        self.offset as isize - old
    }

    pub(crate) fn scroll_up(&mut self, amount: usize) -> isize {
        self.refresh_bounds();
        let old = self.offset as isize;
        self.offset = self.offset.saturating_sub(amount);
        self.offset as isize - old
    }

    pub(crate) fn jump_to_start(&mut self) -> isize {
        self.refresh_bounds();
        let old = self.offset as isize;
        self.offset = 0;
        self.offset as isize - old
    }

    pub(crate) fn jump_to_end(&mut self) -> isize {
        self.refresh_bounds();
        let old = self.offset as isize;
        self.offset = self.max_offset;
        self.offset as isize - old
    }
}

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

    fn text(n: usize) -> String {
        (0..n)
            .map(|i| format!("line {i}"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn viewer(input: &str, height: u16) -> TxtView {
        let config = TxtViewConfig {
            viewport_height: Some(height),
            viewport_width: Some(80),
            ..TxtViewConfig::default()
        };
        TxtView::new(input).with_config(config)
    }

    #[test]
    fn scroll_down_moves_offset() {
        let mut v = viewer(&text(25), 10);
        v.scroll_down(5);
        assert_eq!(v.offset, 5);
    }

    #[test]
    fn scroll_to_clamps_at_end() {
        let mut v = viewer(&text(25), 10);
        v.scroll_to(1000);
        assert_eq!(v.offset, v.max_offset);
    }

    #[test]
    fn scroll_to_saturates_at_zero() {
        let mut v = viewer(&text(25), 10);
        v.scroll_down(5);
        v.scroll_to(0);
        assert_eq!(v.offset, 0);
    }

    #[test]
    fn scroll_to_moves_to_absolute_position() {
        let mut v = viewer(&text(25), 10);
        v.scroll_to(7);
        assert_eq!(v.offset, 7);
    }

    #[test]
    fn scroll_down_clamps_at_end() {
        let mut v = viewer(&text(25), 10);
        v.scroll_down(100);
        assert_eq!(v.offset, v.max_offset);
        assert_eq!(v.max_offset, 17);
    }

    #[test]
    fn scroll_up_saturates_at_zero() {
        let mut v = viewer(&text(25), 10);
        v.scroll_down(5);
        v.scroll_up(100);
        assert_eq!(v.offset, 0);
    }

    #[test]
    fn scroll_up_moves_offset_back() {
        let mut v = viewer(&text(25), 10);
        v.scroll_down(7);
        v.scroll_up(2);
        assert_eq!(v.offset, 5);
    }

    #[test]
    fn jump_to_end_stops_at_max_offset() {
        let mut v = viewer(&text(25), 10);
        v.jump_to_end();
        assert_eq!(v.offset, v.max_offset);
        assert_eq!(v.offset, 17);
    }

    #[test]
    fn jump_to_start_resets_offset() {
        let mut v = viewer(&text(25), 10);
        v.scroll_down(6);
        v.jump_to_start();
        assert_eq!(v.offset, 0);
    }

    #[test]
    fn small_file_cannot_scroll() {
        let mut v = viewer(&text(5), 10);
        assert_eq!(v.max_offset, 0);
        v.scroll_down(2);
        assert_eq!(v.offset, 0);
    }

    #[test]
    fn hidden_help_bar_increases_viewport() {
        let config = TxtViewConfig {
            show_help_bar: false,
            viewport_height: Some(10),
            viewport_width: Some(80),
            ..TxtViewConfig::default()
        };
        let mut v = TxtView::new(text(25)).with_config(config);
        v.jump_to_end();
        assert_eq!(v.offset, 15);
    }

    #[test]
    fn long_lines_wrap_into_multiple_rows() {
        let row = "12345678901234567890";
        let input = (0..5)
            .map(|_| row.to_string())
            .collect::<Vec<_>>()
            .join("\n");
        let config = TxtViewConfig {
            show_help_bar: false,
            viewport_height: Some(8),
            viewport_width: Some(10),
            ..TxtViewConfig::default()
        };
        let mut v = TxtView::new(&input).with_config(config);

        assert_eq!(v.line_count(), 5);
        assert_eq!(v.max_offset, 2);

        v.scroll_down(1);
        assert_eq!(v.offset, 1);
        v.scroll_down(4);
        assert_eq!(v.offset, v.max_offset);
    }

    #[test]
    fn empty_lines_still_occupy_a_row() {
        let config = TxtViewConfig {
            show_help_bar: false,
            viewport_height: Some(1),
            viewport_width: Some(10),
            ..TxtViewConfig::default()
        };
        let mut v = TxtView::new("\n\n\n").with_config(config);
        assert_eq!(v.line_count(), 3);
        assert_eq!(v.max_offset, 2);
        v.scroll_down(1);
        assert_eq!(v.offset, 1);
    }

    #[test]
    fn line_number_prefix_counts_in_width() {
        let config = TxtViewConfig {
            show_line_numbers: true,
            show_help_bar: false,
            show_scrollbar: false,
            viewport_height: Some(3),
            viewport_width: Some(10),
        };
        let input = "12345678901234567890\n12345678901234567890";
        let mut v = TxtView::new(input).with_config(config);

        assert_eq!(v.line_count(), 2);
        assert_eq!(v.max_offset, 5);
        v.jump_to_end();
        assert_eq!(v.offset, 5);
    }
}