pagers-tui 0.1.6

Terminal UI for pagers page cache diagnostics
Documentation
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;

use pagers_core::mincore::PageMap;

use crate::MAX_DISPLAY_PAGES;
use crate::state::FileState;
use crate::stats;

pub(crate) fn layout(file_rows: u16, area: Rect) -> [Rect; 2] {
    Layout::vertical([
        Constraint::Length(file_rows),
        Constraint::Length(stats::SUMMARY_LINES),
    ])
    .areas(area)
}

pub(crate) struct FileListWidget<'a, PM: PageMap> {
    pub files: &'a [&'a FileState<PM>],
    pub max_rows: u16,
}

impl<PM: PageMap> Widget for FileListWidget<'_, PM> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        if self.files.is_empty() {
            return;
        }
        let visible = &self.files[..self.files.len().min(self.max_rows as usize)];
        let constraints: Vec<Constraint> = visible.iter().map(|_| Constraint::Length(1)).collect();
        let areas = Layout::vertical(constraints).split(area);
        for (i, file) in visible.iter().enumerate() {
            FileRowWidget { file }.render(areas[i], buf);
        }
    }
}

struct FileRowWidget<'a, PM: PageMap> {
    file: &'a FileState<PM>,
}

impl<PM: PageMap> FileRowWidget<'_, PM> {
    fn status_and_style(&self) -> (&'static str, Style) {
        let fully_loaded =
            self.file.total_pages > 0 && self.file.pages_in_core == self.file.total_pages;
        if self.file.done && fully_loaded {
            ("\u{2713} ", Style::default().fg(Color::Green))
        } else if !self.file.done {
            ("  ", Style::default().fg(Color::Cyan))
        } else {
            ("  ", Style::default())
        }
    }

    fn histogram_spans(&self, width: usize, style: Style) -> Vec<Span<'static>> {
        let buckets = self.file.bucketize(width);
        let mut spans = Vec::with_capacity(width + 2);
        spans.push(Span::styled("[", Style::default().fg(Color::DarkGray)));
        for &(cached, total) in &buckets {
            let ratio = if total == 0 {
                0.0
            } else {
                cached as f64 / total as f64
            };
            let (ch, s) = match ratio {
                r if r >= 1.0 => ("#", style),
                r if r >= 0.75 => ("+", style),
                r if r >= 0.50 => ("=", Style::default().fg(Color::Cyan)),
                r if r >= 0.25 => ("-", Style::default().fg(Color::DarkGray)),
                r if r > 0.0 => (".", Style::default().fg(Color::DarkGray)),
                _ => (" ", Style::default()),
            };
            spans.push(Span::styled(ch, s));
        }
        spans.push(Span::styled("]", Style::default().fg(Color::DarkGray)));
        spans
    }
}

impl<PM: PageMap> Widget for FileRowWidget<'_, PM> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let (status, cached_style) = self.status_and_style();
        let counter = format!(" {}/{}", self.file.pages_in_core, self.file.total_pages);

        let map_inner = self.file.total_pages.min(MAX_DISPLAY_PAGES);
        let map_chars = if map_inner > 0 { map_inner + 3 } else { 0 };
        let path_budget = (area.width as usize).saturating_sub(2 + map_chars + counter.len());
        let display_path = truncate_path(&self.file.path, path_budget);

        let mut spans = vec![
            Span::styled(status, cached_style),
            Span::styled(display_path, cached_style),
        ];

        if map_inner > 0 {
            spans.push(Span::raw(" "));
            spans.extend(self.histogram_spans(map_inner, cached_style));
        }

        spans.push(Span::styled(counter, Style::default().fg(Color::DarkGray)));

        Line::from(spans).render(area, buf);
    }
}

fn truncate_path(path: &str, max_width: usize) -> String {
    let char_count = path.chars().count();
    if char_count <= max_width {
        return path.to_string();
    }
    if max_width <= 1 {
        return "\u{2026}".to_string();
    }
    let skip = char_count - (max_width - 1);
    let tail: String = path.chars().skip(skip).collect();
    format!("\u{2026}{tail}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use bitvec::prelude::Lsb0;
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    fn buffer_to_string(buf: &Buffer) -> String {
        let mut s = String::new();
        for y in 0..buf.area.height {
            for x in 0..buf.area.width {
                s.push_str(buf[(x, y)].symbol());
            }
            s.push('\n');
        }
        s
    }

    fn render_file_list(files: &[&FileState], max_rows: u16, width: u16, height: u16) -> String {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| {
                FileListWidget { files, max_rows }.render(frame.area(), frame.buffer_mut())
            })
            .unwrap();
        buffer_to_string(terminal.backend().buffer())
    }

    fn render_single_file(file: &FileState, width: u16) -> String {
        let backend = TestBackend::new(width, 1);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| FileRowWidget { file }.render(frame.area(), frame.buffer_mut()))
            .unwrap();
        buffer_to_string(terminal.backend().buffer())
    }

    #[test]
    fn test_render_empty_is_blank() {
        let files: Vec<&FileState> = vec![];
        let content = render_file_list(&files, 4, 60, 4);
        assert!(content.trim().is_empty());
    }

    #[test]
    fn test_render_single_file_shows_map_and_counter() {
        let file = FileState {
            path: "/tmp/test.bin".into(),
            total_pages: 100,
            pages_in_core: 75,
            residency: {
                let mut r = bitvec::bitvec![1; 75];
                r.extend(bitvec::bitvec![0; 25]);
                r
            },
            done: false,
        };
        let content = render_file_list(&[&file], 4, 80, 4);
        assert!(content.contains("test.bin"));
        assert!(content.contains("75/100"));
        assert!(content.contains('['));
        assert!(content.contains(']'));
    }

    #[test]
    fn test_render_files_sorted_by_ratio() {
        let high = FileState {
            path: "/high.bin".into(),
            total_pages: 100,
            pages_in_core: 90,
            residency: bitvec::bitvec![1; 100],
            done: false,
        };
        let low = FileState {
            path: "/low.bin".into(),
            total_pages: 100,
            pages_in_core: 10,
            residency: bitvec::bitvec![0; 100],
            done: false,
        };
        let mut files: Vec<&FileState> = vec![&high, &low];
        files.sort_by(|a, b| a.ratio().total_cmp(&b.ratio()));
        let backend = TestBackend::new(80, 4);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| {
                FileListWidget {
                    files: &files,
                    max_rows: 4,
                }
                .render(frame.area(), frame.buffer_mut())
            })
            .unwrap();
        let buf = terminal.backend().buffer().clone();
        let row0: String = (0..buf.area.width)
            .map(|x| buf[(x, 0)].symbol().chars().next().unwrap_or(' '))
            .collect();
        assert!(row0.contains("low.bin"));
    }

    #[test]
    fn test_file_state_ratio() {
        let f = FileState {
            path: "test".into(),
            total_pages: 200,
            pages_in_core: 100,
            residency: bitvec::bitvec![1; 200],
            done: false,
        };
        assert!((f.ratio() - 0.5).abs() < f64::EPSILON);
        let empty = FileState {
            path: "empty".into(),
            total_pages: 0,
            pages_in_core: 0,
            residency: bitvec::bitvec![],
            done: false,
        };
        assert!((empty.ratio() - 0.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_done_file_shows_checkmark() {
        let file = FileState {
            path: "/tmp/done.bin".into(),
            total_pages: 100,
            pages_in_core: 100,
            residency: bitvec::bitvec![1; 100],
            done: true,
        };
        let content = render_single_file(&file, 80);
        assert!(content.contains("\u{2713}"), "expected checkmark in output");
    }

    #[test]
    fn test_truncate_path_char_based() {
        assert_eq!(truncate_path("abcdef", 6), "abcdef");
        assert_eq!(truncate_path("abcdef", 4), "\u{2026}def");
        assert_eq!(truncate_path("abcdef", 1), "\u{2026}");

        let mb = "\u{00e9}\u{00e9}\u{00e9}\u{00e9}";
        assert_eq!(truncate_path(mb, 4), mb.to_string());
        assert_eq!(truncate_path(mb, 3), format!("\u{2026}\u{00e9}\u{00e9}"));
    }

    #[test]
    fn test_page_map_shows_cached_regions() {
        let mut residency = bitvec::bitvec![1; 50];
        residency.extend(bitvec::bitvec![0; 50]);
        let file = FileState {
            path: "/test.bin".into(),
            total_pages: 100,
            pages_in_core: 50,
            residency,
            done: false,
        };
        let content = render_single_file(&file, 80);
        assert!(content.contains('#') || content.contains('+'));
        assert!(content.contains("50/100"));
    }

    #[test]
    fn test_bucketize() {
        let file = FileState {
            path: "t".into(),
            total_pages: 10,
            pages_in_core: 5,
            residency: bitvec::bitvec![1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
            done: false,
        };
        let buckets = file.bucketize(5);
        assert_eq!(buckets.len(), 5);
        assert_eq!(buckets[0], (2, 2));
        assert_eq!(buckets[1], (2, 2));
        assert_eq!(buckets[2], (1, 2));
        assert_eq!(buckets[3], (0, 2));
        assert_eq!(buckets[4], (0, 2));
    }

    #[test]
    fn test_bucketize_single_page_not_loaded() {
        let file = FileState {
            path: "t".into(),
            total_pages: 1,
            pages_in_core: 0,
            residency: bitvec::bitvec![0; 1],
            done: false,
        };
        let buckets = file.bucketize(1);
        assert_eq!(buckets.len(), 1);
        assert_eq!(buckets[0], (0, 1));
    }

    #[test]
    fn test_bucketize_single_page_loaded() {
        let file = FileState {
            path: "t".into(),
            total_pages: 1,
            pages_in_core: 1,
            residency: bitvec::bitvec![1],
            done: false,
        };
        let buckets = file.bucketize(1);
        assert_eq!(buckets.len(), 1);
        assert_eq!(buckets[0], (1, 1));
    }
}