use ratatui_core::layout::Rect;
use ratatui_core::text::Line;
use crate::event::{Event, InputOutcome, KeyCode, MouseKind};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
use super::text::wrap_lines;
use super::{Scrollbar, VirtualWindow};
#[derive(Clone, Copy, Debug)]
pub struct ScrollState {
offset: usize,
x_offset: usize,
stick_to_bottom: bool,
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
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 {
VirtualWindow::max_start_for(content_h, viewport_h)
}
pub fn max_x_offset(content_w: usize, viewport_w: usize) -> usize {
VirtualWindow::max_start_for(content_w, 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) -> InputOutcome {
let changed = self.offset != 0 || self.stick_to_bottom;
self.offset = self.offset.saturating_sub(lines);
self.stick_to_bottom = false;
if changed {
InputOutcome::Changed
} else {
InputOutcome::Consumed
}
}
fn scroll_down(&mut self, lines: usize, content_h: usize, viewport_h: usize) -> InputOutcome {
let max = Self::max_offset(content_h, viewport_h);
let changed = self.offset != max || !self.stick_to_bottom;
self.offset = self.offset.saturating_add(lines).min(max);
self.stick_to_bottom = self.offset >= max;
if changed {
InputOutcome::Changed
} else {
InputOutcome::Consumed
}
}
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) -> InputOutcome {
let page = viewport_h.saturating_sub(1).max(1);
match event {
Event::Mouse(m) => match m.kind {
MouseKind::ScrollUp => self.scroll_up(3),
MouseKind::ScrollDown => self.scroll_down(3, content_h, viewport_h),
_ => InputOutcome::Ignored,
},
Event::Key(k) if k.plain() => match k.code {
KeyCode::PageUp => self.scroll_up(page),
KeyCode::PageDown => self.scroll_down(page, content_h, viewport_h),
KeyCode::Home => {
let changed = self.offset != 0 || self.stick_to_bottom;
self.jump_to_top();
if changed {
InputOutcome::Changed
} else {
InputOutcome::Consumed
}
}
KeyCode::End => {
let max = Self::max_offset(content_h, viewport_h);
let changed = self.offset != max || !self.stick_to_bottom;
self.jump_to_bottom(content_h, viewport_h);
if changed {
InputOutcome::Changed
} else {
InputOutcome::Consumed
}
}
_ => InputOutcome::Ignored,
},
_ => InputOutcome::Ignored,
}
}
}
pub struct Scroll {
lines: Vec<Line<'static>>,
window_start: usize,
content_height: usize,
offset: usize,
x_offset: usize,
stick_to_bottom: bool,
scrollbar: bool,
wrap: bool,
windowed: 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(),
stick_to_bottom: state.is_stuck_to_bottom(),
scrollbar: true,
wrap: false,
windowed: false,
}
}
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(),
stick_to_bottom: state.is_stuck_to_bottom(),
scrollbar: true,
wrap: false,
windowed: true,
}
}
pub fn scrollbar(mut self, show: bool) -> Self {
self.scrollbar = show;
self
}
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
pub fn content_height(&self) -> usize {
self.content_height
}
}
impl View for Scroll {
fn measure(&self, available: Size, _ctx: &RenderCtx) -> Size {
let content_height = if self.wrap && !self.windowed {
wrap_lines(&self.lines, available.width).len()
} else {
self.content_height()
};
let intrinsic_h = 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 should_wrap = self.wrap && !self.windowed;
let mut wrapped = if should_wrap {
wrap_lines(&self.lines, area.width)
} else {
Vec::new()
};
let mut content_h = if should_wrap {
wrapped.len()
} else {
self.content_height()
};
let mut overflow = content_h > area.height as usize;
let text_width = if overflow && self.scrollbar {
area.width.saturating_sub(1)
} else {
area.width
};
if should_wrap && text_width < area.width {
wrapped = wrap_lines(&self.lines, text_width);
content_h = wrapped.len();
overflow = content_h > area.height as usize;
}
let lines = if should_wrap { &wrapped } else { &self.lines };
let window_start = if should_wrap { 0 } else { self.window_start };
let max_offset = ScrollState::max_offset(content_h, area.height as usize);
let offset = if should_wrap && self.stick_to_bottom {
max_offset
} else if should_wrap {
self.offset.min(max_offset)
} else {
self.offset
};
for row in 0..area.height {
let Some(idx) = (offset + row as usize).checked_sub(window_start) else {
break;
};
let Some(line) = 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 = if should_wrap {
0
} else {
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(),
line.style.patch(span.style),
&mut skip,
);
}
}
if overflow && self.scrollbar && text_width < area.width {
Scrollbar::vertical(VirtualWindow::new(
content_h,
usize::from(area.height),
offset,
))
.render(
Rect::new(area.right() - 1, area.y, 1, area.height),
surface,
ctx,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Surface;
use crate::event::{Event, InputOutcome, Key, KeyCode, Mouse, MouseKind};
use crate::style::Theme;
use crate::tests::support::{buffer, rainbow_theme, row};
use crate::view::{RenderCtx, View};
use ratatui_core::style::Color;
use ratatui_core::style::Style;
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), InputOutcome::Changed);
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), InputOutcome::Changed);
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 scroll_wraps_at_render_width_before_windowing() {
let mut state = ScrollState::new();
state.jump_to_top();
let scroll = Scroll::new(
vec![Line::styled(
"the quick brown fox",
Style::default().fg(Color::Blue),
)],
&state,
)
.wrap(true)
.scrollbar(false);
let buf = crate::testing::render(&scroll, 9, 3, &Theme::default());
assert_eq!(
crate::testing::grid(&buf),
"the quick\nbrown fox\n "
);
assert_eq!(buf[(0, 1)].fg, Color::Blue);
}
#[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 scrolling_back_to_the_bottom_resumes_following() {
let mut s = ScrollState::new();
s.clamp(100, 10);
assert_eq!(s.offset(), 90);
let up = Event::Mouse(Mouse::at(MouseKind::ScrollUp, 0, 0));
let _ = s.handle(&up, 100, 10);
let _ = s.handle(&up, 100, 10);
assert_eq!(s.offset(), 84);
assert!(!s.is_stuck_to_bottom());
s.clamp(130, 10);
assert_eq!(s.offset(), 84, "content growth must not yank a reader back");
let down = Event::Key(Key::new(KeyCode::PageDown));
for _ in 0..8 {
if s.is_stuck_to_bottom() {
break;
}
let _ = s.handle(&down, 130, 10);
}
assert!(
s.is_stuck_to_bottom(),
"reaching the bottom resumes following"
);
assert_eq!(s.offset(), 120);
s.clamp(160, 10);
assert_eq!(s.offset(), 150);
}
#[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), InputOutcome::Changed);
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));
let _ = 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:?}");
}
}