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
);
}
}
}