Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions tui-scrollview/src/scroll_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,6 @@ impl ScrollView {
///
/// This should not be confused with the `render` method, which renders the visible area of the
/// ScrollView into the main buffer.

pub fn render_stateful_widget<W: StatefulWidget>(
&mut self,
widget: W,
Expand Down Expand Up @@ -451,6 +450,57 @@ mod tests {
)
}

#[rstest]
fn move_to_bottom(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 6));
let mut state = ScrollViewState::default();

// Prior rendering, page and buffer size are unkown. We default to `true`.
assert!(state.is_at_bottom());

scroll_view.clone().render(buf.area, &mut buf, &mut state);

// The vertical view size is five which means the page size is five.
// We have not scrolled yet, view is at the top and not the at the bottom.
// => We see the top five rows
assert!(!state.is_at_bottom());

// Since the content height is ten,
assert_eq!(state.size.unwrap().height, 10);
// if we scroll down one page (five rows),
state.scroll_down();
state.scroll_down();
state.scroll_down();
state.scroll_down();
state.scroll_down();

// we reach the bottom,
assert!(state.is_at_bottom());
assert_eq!(state.offset.y, 5);

// and we see the last five rows of the content.
scroll_view.render(buf.area, &mut buf, &mut state);
assert_eq!(
buf,
Buffer::with_lines(vec![
"YZABC▲",
"IJKLM║",
"STUVW█",
"CDEFG█",
"MNOPQ▼",
"◄██═► ",
])
);

// We could also jump directly to the bottom...
state.scroll_to_bottom();
assert!(state.is_at_bottom());

// ...which sets the offset to the last row of content,
// ensuring to be at the bottom regardless of the page size.
assert_eq!(state.offset.y, state.size.unwrap().height - 1);
}

#[rstest]
fn hides_both_scrollbars(scroll_view: ScrollView) {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
Expand Down Expand Up @@ -790,7 +840,7 @@ mod tests {
let items: Vec<String> = (1..=10).map(|i| format!("Item {}", i)).collect();
let list = List::new(items);
scroll_view.render_stateful_widget(list, scroll_view.area(), &mut list_state);
scroll_view.render(buf.area, &mut buf, &mut state);
scroll_view.clone().render(buf.area, &mut buf, &mut state);
Comment on lines -793 to +843
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this clone is needed here

Copy link
Author

@Teufelchen1 Teufelchen1 May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is actually reassuring to see that you also don't get it immediately (makes me feel less like an idiot). I was very confused when Rust hit me with this (when you omit that clone()):

error[E0382]: use of moved value: `scroll_view`
   --> tui-scrollview/src/scroll_view.rs:482:9
    |
454 |     fn move_to_bottom(scroll_view: ScrollView) {
    |                       ----------- move occurs because `scroll_view` has type `scroll_view::ScrollView`, which does not implement the `Copy` trait
...
461 |         scroll_view.render(buf.area, &mut buf, &mut state);
    |                     -------------------------------------- `scroll_view` moved due to this method call
...
482 |         scroll_view.render(buf.area, &mut buf, &mut state);
    |         ^^^^^^^^^^^ value used here after move
    |
note: `ratatui::prelude::StatefulWidget::render` takes ownership of the receiver `self`, which moves `scroll_view`
   --> /home/teufelchen/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/ratatui-0.29.0/src/widgets.rs:244:15
    |
244 |     fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State);
    |               ^^^^
help: you can `clone` the value and consume it, but this might not be your desired behavior
    |
461 |         scroll_view.clone().render(buf.area, &mut buf, &mut state);
    |                    ++++++++

For more information about this error, try `rustc --explain E0382`.

I have to admit I am unable to explain it really. I figured it has something to do with #[fixture] or #[rstest] which I don't know about. Hence I just went with the compiler hint / the clone.

assert_eq!(
buf,
Buffer::with_lines(vec![
Expand Down
17 changes: 17 additions & 0 deletions tui-scrollview/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ impl ScrollViewState {
}

/// Move the scroll view state to the bottom of the buffer
///
/// If the buffer size is not yet computed (done during the first rendering), it will not
/// be taken into account and the scroll offset will be set to the maximum value: `u16::MAX`
pub fn scroll_to_bottom(&mut self) {
// the render call will adjust the offset to ensure that we don't scroll past the end of
// the buffer, so we can set the offset to the maximum value here
Expand All @@ -82,4 +85,18 @@ impl ScrollViewState {
.map_or(u16::MAX, |size| size.height.saturating_sub(1));
self.offset.y = bottom;
}

/// True if the scroll view state is at the bottom of the buffer
///
/// This takes the page size into account. It returns true if the current scroll offset plus
/// the page size matches or exceeds the buffer length.
///
/// The buffer and the page size are unknown until computed during the first rendering. If the
/// page size is not yet known, it won't be taken into account. If the buffer is not yet known,
/// this function always returns true.
pub fn is_at_bottom(&self) -> bool {
let bottom = self.size.map_or(0, |size| size.height.saturating_sub(1));
let page_size = self.page_size.map_or(0, |size| size.height);
self.offset.y.saturating_add(page_size) >= bottom
}
}