use ratatui::layout::Rect;
use ratatui::style::Style;
use super::theme::THEME;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::ui) struct ScrollViewport {
pub(in crate::ui) content_len: usize,
pub(in crate::ui) viewport_len: usize,
pub(in crate::ui) offset: usize,
}
impl ScrollViewport {
pub(in crate::ui) fn from_offset(
content_len: usize,
viewport_len: usize,
offset: usize,
) -> Self {
Self {
content_len,
viewport_len,
offset: clamp_offset(offset, content_len, viewport_len),
}
}
pub(in crate::ui) fn for_selection_with_margin(
content_len: usize,
viewport_len: usize,
selection: usize,
margin: usize,
) -> Self {
let margin = margin.min(viewport_len.saturating_sub(1));
let selected_row = viewport_len.saturating_sub(1 + margin);
let offset = if viewport_len == 0 {
0
} else {
selection.saturating_sub(selected_row)
};
Self::from_offset(content_len, viewport_len, offset)
}
pub(in crate::ui) fn content_area(self, area: Rect) -> Rect {
if !self.has_overflow() || area.width <= 1 {
return area;
}
Rect {
width: area.width - 1,
..area
}
}
pub(in crate::ui) fn has_overflow(self) -> bool {
self.viewport_len > 0 && self.content_len > self.viewport_len
}
pub(in crate::ui) fn offset_u16(self) -> u16 {
self.offset.min(usize::from(u16::MAX)) as u16
}
fn thumb(self, track_len: usize) -> Option<ScrollbarThumb> {
if !self.has_overflow() || track_len == 0 {
return None;
}
let max_offset = max_offset(self.content_len, self.viewport_len);
if max_offset == 0 {
return None;
}
let thumb_len = self
.viewport_len
.saturating_mul(track_len)
.saturating_add(self.content_len.saturating_sub(1))
/ self.content_len;
let thumb_len = thumb_len.clamp(1, track_len);
let travel = track_len.saturating_sub(thumb_len);
let start = self
.offset
.saturating_mul(travel)
.saturating_add(max_offset / 2)
/ max_offset;
Some(ScrollbarThumb {
start,
end: start + thumb_len,
})
}
}
pub(in crate::ui) fn scroll_viewport_for_visible_line(
content_len: usize,
viewport_len: usize,
current_offset: usize,
line: Option<usize>,
margin: usize,
) -> ScrollViewport {
let mut offset = clamp_offset(current_offset, content_len, viewport_len);
let Some(line) = line else {
return ScrollViewport::from_offset(content_len, viewport_len, offset);
};
if viewport_len == 0 {
return ScrollViewport::from_offset(content_len, viewport_len, 0);
}
let margin = margin.min(viewport_len.saturating_sub(1) / 2);
let upper = offset.saturating_add(margin);
let lower = offset
.saturating_add(viewport_len)
.saturating_sub(1 + margin);
if line < upper {
offset = line.saturating_sub(margin);
} else if line > lower {
offset = line.saturating_sub(viewport_len.saturating_sub(1 + margin));
}
ScrollViewport::from_offset(content_len, viewport_len, offset)
}
pub(in crate::ui) fn viewport_comfort_margin(viewport_len: usize) -> usize {
if viewport_len < 8 {
0
} else {
(viewport_len / 5).clamp(1, 4)
}
}
pub(in crate::ui) fn render_vertical_scrollbar(
frame: &mut ratatui::Frame<'_>,
area: Rect,
viewport: ScrollViewport,
) {
let Some(thumb) = viewport.thumb(usize::from(area.height)) else {
return;
};
if area.width == 0 {
return;
}
let x = area.right().saturating_sub(1);
for row in 0..usize::from(area.height) {
let (symbol, color) = if row >= thumb.start && row < thumb.end {
("┃", THEME.scrollbar.thumb)
} else {
("│", THEME.scrollbar.track)
};
frame
.buffer_mut()
.set_string(x, area.y + row as u16, symbol, Style::default().fg(color));
}
}
fn clamp_offset(offset: usize, content_len: usize, viewport_len: usize) -> usize {
offset.min(max_offset(content_len, viewport_len))
}
fn max_offset(content_len: usize, viewport_len: usize) -> usize {
content_len.saturating_sub(viewport_len)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ScrollbarThumb {
start: usize,
end: usize,
}
#[cfg(any())]
mod tests {
use super::*;
#[test]
fn selection_viewport_keeps_selected_row_visible_at_bottom() {
let viewport = ScrollViewport::for_selection_with_margin(100, 10, 24, 0);
assert_eq!(viewport.offset, 15);
assert_eq!(viewport.content_len, 100);
assert_eq!(viewport.viewport_len, 10);
}
#[test]
fn selection_viewport_can_keep_a_bottom_comfort_margin() {
let viewport = ScrollViewport::for_selection_with_margin(100, 10, 24, 2);
assert_eq!(viewport.offset, 17);
assert_eq!(24 - viewport.offset, 7);
}
#[test]
fn comfort_margin_scales_with_viewport_height() {
assert_eq!(viewport_comfort_margin(7), 0);
assert_eq!(viewport_comfort_margin(10), 2);
assert_eq!(viewport_comfort_margin(40), 4);
}
#[test]
fn selected_line_inside_comfort_zone_keeps_current_offset() {
let viewport = scroll_viewport_for_visible_line(100, 10, 20, Some(25), 2);
assert_eq!(viewport.offset, 20);
}
#[test]
fn selected_line_below_comfort_zone_scrolls_down() {
let viewport = scroll_viewport_for_visible_line(100, 10, 20, Some(29), 2);
assert_eq!(viewport.offset, 22);
}
#[test]
fn selected_line_above_comfort_zone_scrolls_up() {
let viewport = scroll_viewport_for_visible_line(100, 10, 20, Some(21), 2);
assert_eq!(viewport.offset, 19);
}
#[test]
fn viewport_clamps_offset_to_content_end() {
let viewport = ScrollViewport::from_offset(12, 5, 100);
assert_eq!(viewport.offset, 7);
}
#[test]
fn viewport_without_overflow_has_zero_offset() {
let viewport = ScrollViewport::from_offset(3, 10, 8);
assert_eq!(viewport.offset, 0);
assert!(!viewport.has_overflow());
}
#[test]
fn thumb_size_is_proportional_to_visible_content() {
let viewport = ScrollViewport::from_offset(100, 10, 0);
assert_eq!(
viewport.thumb(20),
Some(ScrollbarThumb { start: 0, end: 2 })
);
}
#[test]
fn thumb_reaches_track_end_at_last_full_viewport() {
let viewport = ScrollViewport::from_offset(100, 10, 90);
assert_eq!(
viewport.thumb(20),
Some(ScrollbarThumb { start: 18, end: 20 })
);
}
#[test]
fn content_area_reserves_scrollbar_column_only_when_needed() {
let area = Rect::new(2, 3, 10, 4);
assert_eq!(
ScrollViewport::from_offset(20, 4, 0).content_area(area),
Rect::new(2, 3, 9, 4)
);
assert_eq!(
ScrollViewport::from_offset(4, 4, 0).content_area(area),
area
);
}
}