use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
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) fn render_refs_to_buf<PM: PageMap>(
files: &[&FileState<PM>],
max_rows: u16,
area: ratatui::layout::Rect,
buf: &mut ratatui::buffer::Buffer,
) {
if files.is_empty() {
return;
}
let visible = &files[..files.len().min(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() {
render_file_row_to_buf(file, areas[i], buf);
}
}
pub(crate) fn render_file_row_to_buf<PM: PageMap>(
file: &FileState<PM>,
area: ratatui::layout::Rect,
buf: &mut ratatui::buffer::Buffer,
) {
use ratatui::widgets::Widget;
let fully_loaded = file.total_pages > 0 && file.pages_in_core == file.total_pages;
let counter = format!(" {}/{}", file.pages_in_core, file.total_pages);
let (status, cached_style) = if file.done && fully_loaded {
("\u{2713} ", Style::default().fg(Color::Green))
} else if !file.done {
(" ", Style::default().fg(Color::Cyan))
} else {
(" ", Style::default())
};
let map_inner = 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(&file.path, path_budget);
let mut spans = vec![
Span::styled(status, cached_style),
Span::styled(display_path, cached_style),
];
if map_inner > 0 {
let buckets = file.bucketize(map_inner);
spans.push(Span::raw(" "));
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, style) = match ratio {
r if r >= 1.0 => ("#", cached_style),
r if r >= 0.75 => ("+", cached_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, style));
}
spans.push(Span::styled("]", Style::default().fg(Color::DarkGray)));
}
spans.push(Span::styled(counter, Style::default().fg(Color::DarkGray)));
Line::from(spans).render(area, buf);
}
pub(crate) 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;
use ratatui::buffer::Buffer;
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
}
#[test]
fn test_render_empty_is_blank() {
let backend = TestBackend::new(60, 4);
let mut terminal = Terminal::new(backend).unwrap();
let files: Vec<&FileState> = vec![];
terminal
.draw(|frame| render_refs_to_buf(&files, 4, frame.area(), frame.buffer_mut()))
.unwrap();
let content = buffer_to_string(terminal.backend().buffer());
assert!(content.trim().is_empty());
}
#[test]
fn test_render_single_file_shows_map_and_counter() {
let file = FileState {
path: "/tmp/test.bin".to_string(),
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 files: Vec<&FileState> = vec![&file];
let backend = TestBackend::new(80, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_refs_to_buf(&files, 4, frame.area(), frame.buffer_mut()))
.unwrap();
let content = buffer_to_string(terminal.backend().buffer());
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".to_string(),
total_pages: 100,
pages_in_core: 90,
residency: bitvec::bitvec![1; 100], done: false,
};
let low = FileState {
path: "/low.bin".to_string(),
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| render_refs_to_buf(&files, 4, 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".to_string(),
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".to_string(),
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".to_string(),
total_pages: 100,
pages_in_core: 100,
residency: bitvec::bitvec![1; 100],
done: true,
};
let backend = TestBackend::new(80, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_file_row_to_buf(&file, frame.area(), frame.buffer_mut()))
.unwrap();
let content = buffer_to_string(terminal.backend().buffer());
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".to_string(),
total_pages: 100,
pages_in_core: 50,
residency,
done: false,
};
let backend = TestBackend::new(80, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_file_row_to_buf(&file, frame.area(), frame.buffer_mut()))
.unwrap();
let content = buffer_to_string(terminal.backend().buffer());
assert!(content.contains('#') || content.contains('+'));
assert!(content.contains("50/100"));
}
#[test]
fn test_bucketize() {
let file = FileState {
path: "t".to_string(),
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".to_string(),
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".to_string(),
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));
}
}