use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use ratatui_core::text::Line;
use crate::event::{Event, EventFlow, KeyCode, MouseKind};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
#[derive(Clone, Copy, Debug, Default)]
pub struct ScrollState {
offset: usize,
x_offset: usize,
stick_to_bottom: bool,
}
impl ScrollState {
pub fn new() -> Self {
Self {
offset: 0,
x_offset: 0,
stick_to_bottom: true,
}
}
pub fn offset(&self) -> usize {
self.offset
}
pub fn set_offset(&mut self, offset: usize) {
self.offset = offset;
self.stick_to_bottom = false;
}
pub fn x_offset(&self) -> usize {
self.x_offset
}
pub fn set_x_offset(&mut self, cols: usize) {
self.x_offset = cols;
}
pub fn is_stuck_to_bottom(&self) -> bool {
self.stick_to_bottom
}
pub fn max_offset(content_h: usize, viewport_h: usize) -> usize {
content_h.saturating_sub(viewport_h)
}
pub fn max_x_offset(content_w: usize, viewport_w: usize) -> usize {
content_w.saturating_sub(viewport_w)
}
pub fn clamp(&mut self, content_h: usize, viewport_h: usize) {
let max = Self::max_offset(content_h, viewport_h);
if self.stick_to_bottom {
self.offset = max;
} else {
self.offset = self.offset.min(max);
}
}
pub fn clamp_x(&mut self, content_w: usize, viewport_w: usize) {
self.x_offset = self.x_offset.min(Self::max_x_offset(content_w, viewport_w));
}
fn scroll_up(&mut self, lines: usize) {
self.offset = self.offset.saturating_sub(lines);
self.stick_to_bottom = false;
}
fn scroll_down(&mut self, lines: usize, content_h: usize, viewport_h: usize) {
let max = Self::max_offset(content_h, viewport_h);
self.offset = self.offset.saturating_add(lines).min(max);
self.stick_to_bottom = self.offset >= max;
}
pub fn jump_to_bottom(&mut self, content_h: usize, viewport_h: usize) {
self.offset = Self::max_offset(content_h, viewport_h);
self.stick_to_bottom = true;
}
pub fn jump_to_top(&mut self) {
self.offset = 0;
self.stick_to_bottom = false;
}
pub fn handle(&mut self, event: &Event, content_h: usize, viewport_h: usize) -> EventFlow {
let page = viewport_h.saturating_sub(1).max(1);
match event {
Event::Mouse(m) => match m.kind {
MouseKind::ScrollUp => {
self.scroll_up(3);
EventFlow::Consumed
}
MouseKind::ScrollDown => {
self.scroll_down(3, content_h, viewport_h);
EventFlow::Consumed
}
_ => EventFlow::Ignored,
},
Event::Key(k) if k.plain() => match k.code {
KeyCode::PageUp => {
self.scroll_up(page);
EventFlow::Consumed
}
KeyCode::PageDown => {
self.scroll_down(page, content_h, viewport_h);
EventFlow::Consumed
}
KeyCode::Home => {
self.jump_to_top();
EventFlow::Consumed
}
KeyCode::End => {
self.jump_to_bottom(content_h, viewport_h);
EventFlow::Consumed
}
_ => EventFlow::Ignored,
},
_ => EventFlow::Ignored,
}
}
}
pub struct Scroll {
lines: Vec<Line<'static>>,
window_start: usize,
content_height: usize,
offset: usize,
x_offset: usize,
scrollbar: bool,
}
impl Scroll {
pub fn new(lines: Vec<Line<'static>>, state: &ScrollState) -> Self {
Self {
content_height: lines.len(),
lines,
window_start: 0,
offset: state.offset(),
x_offset: state.x_offset(),
scrollbar: true,
}
}
pub fn windowed(
window: Vec<Line<'static>>,
content_height: usize,
state: &ScrollState,
) -> Self {
let offset = state.offset();
Self {
lines: window,
window_start: offset,
content_height,
offset,
x_offset: state.x_offset(),
scrollbar: true,
}
}
pub fn scrollbar(mut self, show: bool) -> Self {
self.scrollbar = show;
self
}
pub fn content_height(&self) -> usize {
self.content_height
}
}
impl View for Scroll {
fn measure(&self, available: Size) -> Size {
let intrinsic_h = self.content_height().min(u16::MAX as usize) as u16;
Size::new(available.width, intrinsic_h)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
let content_h = self.content_height();
let overflow = content_h > area.height as usize;
let text_width = if overflow && self.scrollbar {
area.width.saturating_sub(1)
} else {
area.width
};
for row in 0..area.height {
let Some(idx) = (self.offset + row as usize).checked_sub(self.window_start) else {
break;
};
let Some(line) = self.lines.get(idx) else {
break;
};
let y = area.y + row;
let mut clip = surface.child(Rect::new(area.x, y, text_width, 1));
let mut skip = self.x_offset.min(u16::MAX as usize) as u16;
let mut x = area.x;
for span in &line.spans {
if x >= area.x + text_width {
break;
}
x = clip.set_string_skip(x, y, span.content.as_ref(), span.style, &mut skip);
}
}
if overflow && self.scrollbar && text_width < area.width {
self.draw_scrollbar(area, content_h, surface, ctx);
}
}
}
impl Scroll {
fn draw_scrollbar(&self, area: Rect, content_h: usize, surface: &mut Surface, ctx: &RenderCtx) {
let track_x = area.right() - 1;
let track_h = area.height;
let track_h_u = track_h as usize;
let content_h = content_h.max(1);
let max_offset = content_h.saturating_sub(area.height as usize).max(1);
let thumb_h = ((track_h_u * track_h_u) / content_h).max(1).min(track_h_u) as u16;
let travel = track_h.saturating_sub(thumb_h);
let thumb_y = area.y + ((self.offset * travel as usize) / max_offset) as u16;
let track_style = Style::default().fg(ctx.theme.dim);
let thumb_style = Style::default().fg(ctx.theme.muted);
for row in 0..track_h {
let y = area.y + row;
let within = y >= thumb_y && y < thumb_y.saturating_add(thumb_h);
let (glyph, style) = if within {
('█', thumb_style)
} else {
('│', track_style)
};
surface.set(track_x, y, glyph, style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Surface;
use crate::event::{Event, EventFlow, Key, KeyCode, Mouse, MouseKind};
use crate::style::Theme;
use crate::test_support::{buffer, rainbow_theme, row};
use crate::view::{RenderCtx, View};
use ratatui_core::style::Color;
use ratatui_core::text::{Line, Span};
#[test]
fn scroll_sticks_to_bottom_until_scrolled_up() {
let mut s = ScrollState::new();
s.clamp(100, 10);
assert_eq!(s.offset(), 90);
assert!(s.is_stuck_to_bottom());
let up = Event::Mouse(Mouse::at(MouseKind::ScrollUp, 0, 0));
assert_eq!(s.handle(&up, 100, 10), EventFlow::Consumed);
assert!(!s.is_stuck_to_bottom());
assert_eq!(s.offset(), 87);
s.clamp(200, 10);
assert_eq!(s.offset(), 87);
}
#[test]
fn scroll_offset_survives_content_taller_than_u16() {
let content_h = u16::MAX as usize + 5_000; let viewport_h = 40;
let mut s = ScrollState::new();
s.clamp(content_h, viewport_h);
assert_eq!(s.offset(), content_h - viewport_h);
assert!(s.is_stuck_to_bottom());
let up = Event::Key(Key::new(KeyCode::PageUp));
assert_eq!(s.handle(&up, content_h, viewport_h), EventFlow::Consumed);
assert!(!s.is_stuck_to_bottom());
assert_eq!(s.offset(), content_h - viewport_h - (viewport_h - 1));
}
#[test]
fn set_offset_positions_view_and_detaches_stick() {
let mut s = ScrollState::new();
s.clamp(100, 10);
assert!(s.is_stuck_to_bottom(), "starts stuck to bottom");
s.set_offset(40);
assert!(!s.is_stuck_to_bottom());
s.clamp(100, 10);
assert_eq!(s.offset(), 40);
s.set_offset(500);
s.clamp(100, 10);
assert_eq!(s.offset(), 90, "clamped to content height - viewport");
}
#[test]
fn horizontal_pan_offset_clamp_and_max() {
let mut s = ScrollState::new();
assert_eq!(s.x_offset(), 0, "no pan by default");
s.set_x_offset(34);
assert_eq!(s.x_offset(), 34);
s.clamp_x(30, 10);
assert_eq!(s.x_offset(), 20);
s.clamp_x(30, 10);
assert_eq!(s.x_offset(), 20);
assert_eq!(ScrollState::max_offset(100, 10), 90);
assert_eq!(ScrollState::max_offset(5, 10), 0, "floors at zero");
assert_eq!(ScrollState::max_x_offset(30, 10), 20);
}
#[test]
fn scroll_view_pans_long_lines_horizontally() {
let mut state = ScrollState::new();
state.set_x_offset(3);
let scroll = Scroll::new(vec![Line::from("0123456789")], &state);
let mut buf = buffer(5, 1);
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
scroll.render(area, &mut surface, &ctx);
assert_eq!(row(&buf, 0), "34567");
}
#[test]
fn horizontal_pan_is_width_aware_across_spans() {
let mut state = ScrollState::new();
state.set_x_offset(3);
let line = Line::from(vec![
Span::raw("aa你"), Span::raw("好bb"),
]);
let scroll = Scroll::new(vec![line], &state);
let mut buf = buffer(6, 1);
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
scroll.render(area, &mut surface, &ctx);
let rendered = row(&buf, 0);
assert!(
rendered.contains("好"),
"resumes at the next whole glyph: {rendered:?}"
);
assert!(
rendered.contains("bb"),
"pan carried across spans: {rendered:?}"
);
assert!(
!rendered.contains("你"),
"straddling wide glyph dropped: {rendered:?}"
);
}
#[test]
fn scroll_end_key_rearms_bottom_stick() {
let mut s = ScrollState::new();
s.clamp(100, 10);
s.jump_to_top();
assert_eq!(s.offset(), 0);
assert!(!s.is_stuck_to_bottom());
let end = Event::Key(Key::new(KeyCode::End));
assert_eq!(s.handle(&end, 100, 10), EventFlow::Consumed);
assert_eq!(s.offset(), 90);
assert!(s.is_stuck_to_bottom());
}
#[test]
fn scroll_view_windows_content_and_draws_scrollbar() {
let lines: Vec<Line<'static>> = (0..20).map(|i| Line::from(format!("line{i}"))).collect();
let mut state = ScrollState::new();
state.clamp(20, 5); let scroll = Scroll::new(lines, &state);
let mut buf = buffer(10, 5);
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
scroll.render(area, &mut surface, &ctx);
assert!(row(&buf, 0).starts_with("line15"));
assert!(row(&buf, 4).starts_with("line19"));
let has_bar = (0..5).any(|y| {
let c = buf[(9, y)].symbol().to_string();
c == "█" || c == "│"
});
assert!(has_bar, "expected a scrollbar in the right column");
}
#[test]
fn scroll_windowed_matches_full_render() {
let content: Vec<Line<'static>> =
(0..1000).map(|i| Line::from(format!("line{i}"))).collect();
let (width, height) = (12u16, 6u16);
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let mut state = ScrollState::new();
state.clamp(content.len(), height as usize);
state.jump_to_top();
let end = Event::Key(Key::new(KeyCode::PageDown));
state.handle(&end, content.len(), height as usize); let offset = state.offset();
assert!(offset > 0 && offset < content.len() - height as usize);
let render = |scroll: Scroll| {
let mut buf = buffer(width, height);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
scroll.render(area, &mut surface, &ctx);
buf
};
let full = render(Scroll::new(content.clone(), &state));
let window = content[offset..offset + height as usize].to_vec();
let windowed = render(Scroll::windowed(window, content.len(), &state));
assert_eq!(
full.content, windowed.content,
"windowed render diverged from the full render"
);
}
#[test]
fn scrollbar_thumb_and_track_use_theme() {
let t = rainbow_theme();
let lines: Vec<Line<'static>> = (0..30).map(|i| Line::from(format!("l{i}"))).collect();
let mut state = ScrollState::new();
state.clamp(30, 5);
let scroll = Scroll::new(lines, &state);
let mut buf = buffer(10, 5);
let area = buf.area;
let ctx = RenderCtx::new(&t);
let mut surface = Surface::new(&mut buf, area);
scroll.render(area, &mut surface, &ctx);
let col = 9; let fgs: Vec<Color> = (0..5).map(|y| buf[(col, y)].fg).collect();
assert!(fgs.contains(&t.muted), "thumb uses theme.muted: {fgs:?}");
assert!(fgs.contains(&t.dim), "track uses theme.dim: {fgs:?}");
}
}