Skip to content

Commit 75f9149

Browse files
authored
Merge pull request #37 from preiter93/scroll-large-items
feat: Add within-item scroll for large items
2 parents 577f9e6 + fe412c7 commit 75f9149

File tree

4 files changed

+203
-42
lines changed

4 files changed

+203
-42
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
Released
22
--------
33

4+
0.15.2 - 28 Mar 2026
5+
===================
6+
- feat: Scroll within large items before selecting the next (co-authored by @orlandohohmeier)
7+
48
0.15.1 - 28 Mar 2026
59
===================
610
- feat: Add ScrollDirection with forward/backward direction

examples/var_sizes.rs

Lines changed: 57 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,28 @@ use common::{Colors, Result, Terminal};
44
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
55
use ratatui::{
66
prelude::*,
7-
widgets::{Block, Borders, Widget},
7+
widgets::{Block, Borders, Scrollbar, Widget},
88
};
99
use tui_widget_list::{ListBuilder, ListState, ListView};
1010

11-
const SIZES: [u16; 19] = [32, 3, 4, 64, 6, 5, 4, 3, 3, 6, 5, 7, 3, 6, 9, 10, 4, 4, 6];
11+
const ITEMS: &[(&str, u16)] = &[
12+
("Header", 3),
13+
("Summary", 5),
14+
("Details", 30),
15+
("Note A", 4),
16+
("Note B", 4),
17+
("Body", 25),
18+
("Sidebar", 6),
19+
("Note C", 3),
20+
("Note D", 3),
21+
("Footer", 5),
22+
];
1223

1324
fn main() -> Result<()> {
1425
let mut terminal = Terminal::init()?;
15-
1626
App::default().run(&mut terminal)?;
17-
1827
Terminal::reset()?;
1928
terminal.show_cursor()?;
20-
2129
Ok(())
2230
}
2331

@@ -29,7 +37,6 @@ impl App {
2937
let mut state = ListState::default();
3038
loop {
3139
terminal.draw_app(self, &mut state)?;
32-
3340
if let Event::Key(key) = event::read()? {
3441
if key.kind == KeyEventKind::Press {
3542
match key.code {
@@ -46,51 +53,61 @@ impl App {
4653

4754
impl StatefulWidget for &App {
4855
type State = ListState;
49-
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
50-
where
51-
Self: Sized,
52-
{
53-
let item_count = SIZES.len();
54-
55-
let block = Block::default().borders(Borders::ALL).title("Outer block");
56+
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
5657
let builder = ListBuilder::new(move |context| {
57-
let size = SIZES[context.index];
58-
let mut widget = LineItem::new(format!("Size: {size}"));
58+
let (title, size) = ITEMS[context.index];
59+
let label = format!(" {} (h={})", title, size);
60+
61+
let style = if context.is_selected {
62+
Style::default().bg(Colors::ORANGE).fg(Colors::CHARCOAL)
63+
} else if context.index % 2 == 0 {
64+
Style::default().bg(Colors::CHARCOAL).fg(Colors::WHITE)
65+
} else {
66+
Style::default().bg(Colors::BLACK).fg(Colors::WHITE)
67+
};
5968

60-
if context.is_selected {
61-
widget.line.style = widget.line.style.bg(Color::White);
69+
let block = Block::default().borders(Borders::ALL).style(style);
70+
let inner = block.inner(Rect::new(0, 0, context.cross_axis_size, size));
71+
let widget = SectionItem {
72+
label,
73+
block,
74+
inner_height: inner.height,
6275
};
6376

64-
return (widget, size);
77+
(widget, size)
6578
});
66-
let list = ListView::new(builder, item_count)
67-
.bg(Color::Black)
68-
.block(block);
79+
80+
let list = ListView::new(builder, ITEMS.len())
81+
.infinite_scrolling(false)
82+
.scrollbar(Scrollbar::default())
83+
.block(
84+
Block::default()
85+
.borders(Borders::ALL)
86+
.title(" Variable Sizes "),
87+
);
6988
list.render(area, buf, state);
7089
}
7190
}
7291

73-
#[derive(Debug, Clone)]
74-
pub struct LineItem<'a> {
75-
line: Line<'a>,
92+
struct SectionItem {
93+
label: String,
94+
block: Block<'static>,
95+
inner_height: u16,
7696
}
7797

78-
impl LineItem<'_> {
79-
pub fn new(text: String) -> Self {
80-
let span = Span::styled(text, Style::default().fg(Colors::TEAL));
81-
let line = Line::from(span).bg(Colors::CHARCOAL);
82-
Self { line }
83-
}
84-
}
85-
86-
impl Widget for LineItem<'_> {
98+
impl Widget for SectionItem {
8799
fn render(self, area: Rect, buf: &mut Buffer) {
88-
let inner = {
89-
let block = Block::default().borders(Borders::ALL);
90-
block.clone().render(area, buf);
91-
block.inner(area)
92-
};
93-
94-
self.line.render(inner, buf);
100+
let inner = self.block.inner(area);
101+
self.block.render(area, buf);
102+
if inner.height > 0 {
103+
Line::from(self.label).render(inner, buf);
104+
}
105+
for row in 1..inner.height.min(self.inner_height) {
106+
let y = inner.y + row;
107+
let filler = format!(" line {}", row);
108+
Line::from(filler)
109+
.style(Style::default().fg(Colors::GRAY))
110+
.render(Rect::new(inner.x, y, inner.width, 1), buf);
111+
}
95112
}
96113
}

src/state.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ pub struct ListState {
2121
/// True by default.
2222
pub(crate) infinite_scrolling: bool,
2323

24+
/// Scroll offset within the currently selected item. When an item is larger
25+
/// than the viewport, this tracks how far we've scrolled into it.
26+
pub(crate) item_scroll: u16,
27+
2428
/// The state for the viewport. Keeps track which item to show
2529
/// first and how much it is truncated.
2630
pub(crate) view_state: ViewState,
@@ -50,6 +54,12 @@ pub(crate) struct ViewState {
5054

5155
/// The scroll direction used during the last render.
5256
pub(crate) scroll_direction: ScrollDirection,
57+
58+
/// The viewport's main axis size from the last render.
59+
pub(crate) last_main_axis_size: u16,
60+
61+
/// Full (untruncated) main-axis sizes of items from the last render.
62+
pub(crate) total_main_axis_sizes: HashMap<usize, u16>,
5363
}
5464

5565
impl Default for ViewState {
@@ -61,6 +71,8 @@ impl Default for ViewState {
6171
inner_area: Rect::default(),
6272
scroll_axis: ScrollAxis::Vertical,
6373
scroll_direction: ScrollDirection::Forward,
74+
last_main_axis_size: 0,
75+
total_main_axis_sizes: HashMap::new(),
6476
}
6577
}
6678
}
@@ -71,6 +83,7 @@ impl Default for ListState {
7183
selected: None,
7284
num_elements: 0,
7385
infinite_scrolling: true,
86+
item_scroll: 0,
7487
view_state: ViewState::default(),
7588
scrollbar_state: ScrollbarState::new(0).position(0),
7689
}
@@ -92,6 +105,7 @@ impl ListState {
92105
/// Selects an item by its index.
93106
pub fn select(&mut self, index: Option<usize>) {
94107
self.selected = index;
108+
self.item_scroll = 0;
95109
if index.is_none() {
96110
self.view_state.offset = 0;
97111
self.scrollbar_state = self.scrollbar_state.position(0);
@@ -113,6 +127,14 @@ impl ListState {
113127
if self.num_elements == 0 {
114128
return;
115129
}
130+
// If the current item overflows the viewport, scroll within it first
131+
if let Some(selected) = self.selected {
132+
let overflow = self.item_overflow(selected);
133+
if overflow > 0 && self.item_scroll < overflow {
134+
self.item_scroll += 1;
135+
return;
136+
}
137+
}
116138
let i = match self.selected {
117139
Some(i) => {
118140
if i >= self.num_elements - 1 {
@@ -145,6 +167,11 @@ impl ListState {
145167
if self.num_elements == 0 {
146168
return;
147169
}
170+
// If the current item overflows the viewport, scroll back within it first
171+
if self.item_scroll > 0 {
172+
self.item_scroll -= 1;
173+
return;
174+
}
148175
let i = match self.selected {
149176
Some(i) => {
150177
if i == 0 {
@@ -159,6 +186,13 @@ impl ListState {
159186
}
160187
None => self.num_elements - 1,
161188
};
189+
// If the previous item overflows the viewport, start at its bottom
190+
let overflow = self.item_overflow(i);
191+
if overflow > 0 {
192+
self.selected = Some(i);
193+
self.item_scroll = overflow;
194+
return;
195+
}
162196
self.select(Some(i));
163197
}
164198

@@ -265,4 +299,30 @@ impl ListState {
265299
pub(crate) fn last_scroll_direction(&self) -> ScrollDirection {
266300
self.view_state.scroll_direction
267301
}
302+
303+
/// Returns how many rows/cols of the item extend beyond the viewport,
304+
/// or 0 if the item fits or its size is not cached.
305+
fn item_overflow(&self, index: usize) -> u16 {
306+
self.view_state
307+
.total_main_axis_sizes
308+
.get(&index)
309+
.map(|&total| total.saturating_sub(self.view_state.last_main_axis_size))
310+
.unwrap_or(0)
311+
}
312+
313+
/// Set the viewport's main axis size from the last render.
314+
pub(crate) fn set_last_main_axis_size(&mut self, size: u16) {
315+
self.view_state.last_main_axis_size = size;
316+
}
317+
318+
/// Set the full (untruncated) main-axis sizes of items from the last render.
319+
pub(crate) fn set_total_main_axis_sizes(&mut self, sizes: HashMap<usize, u16>) {
320+
self.view_state.total_main_axis_sizes = sizes;
321+
}
322+
323+
/// Get the scroll offset within the currently selected item.
324+
#[must_use]
325+
pub(crate) fn item_scroll(&self) -> u16 {
326+
self.item_scroll
327+
}
268328
}

src/view.rs

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ impl<T: Widget> StatefulWidget for ListView<'_, T> {
292292

293293
// Resolve which items are visible and how they fit on the viewport
294294
let (main_axis_size, cross_axis_size) = self.scroll_axis.sizes(inner_area);
295+
state.set_last_main_axis_size(main_axis_size);
295296
let (mut scroll_axis_pos, cross_axis_pos) = self.scroll_axis.origin(inner_area);
296297

297298
let mut viewport = resolve_viewport(
@@ -320,12 +321,31 @@ impl<T: Widget> StatefulWidget for ListView<'_, T> {
320321

321322
// Render each visible item and cache sizes for hit testing
322323
let mut cached_sizes: HashMap<usize, u16> = HashMap::new();
324+
let mut cached_total_sizes: HashMap<usize, u16> = HashMap::new();
325+
let viewport_end = scroll_axis_pos + main_axis_size;
326+
323327
for i in start..end {
324328
let Some(element) = viewport.remove(&i) else {
325329
break;
326330
};
327331

328-
let visible_size = element.visible_size();
332+
cached_total_sizes.insert(i, element.main_axis_size);
333+
334+
// If the selected item overflows the viewport, apply item_scroll as truncation
335+
let truncation = if Some(i) == state.selected
336+
&& state.item_scroll() > 0
337+
&& element.main_axis_size > main_axis_size
338+
{
339+
Truncation::Top(state.item_scroll())
340+
} else {
341+
element.truncation
342+
};
343+
344+
let visible_size = element
345+
.main_axis_size
346+
.saturating_sub(truncation.value())
347+
.min(viewport_end.saturating_sub(scroll_axis_pos));
348+
329349
cached_sizes.insert(i, visible_size);
330350

331351
render_clipped(
@@ -338,7 +358,7 @@ impl<T: Widget> StatefulWidget for ListView<'_, T> {
338358
),
339359
buf,
340360
element.main_axis_size,
341-
&element.truncation,
361+
&truncation,
342362
self.style,
343363
self.scroll_axis,
344364
);
@@ -347,6 +367,7 @@ impl<T: Widget> StatefulWidget for ListView<'_, T> {
347367
}
348368

349369
state.set_visible_main_axis_sizes(cached_sizes);
370+
state.set_total_main_axis_sizes(cached_total_sizes);
350371

351372
if let Some(scrollbar) = self.scrollbar {
352373
scrollbar.render(area, buf, &mut state.scrollbar_state);
@@ -629,6 +650,65 @@ mod test {
629650
)
630651
}
631652

653+
#[test]
654+
fn scroll_within_large_item() {
655+
let area = Rect::new(0, 0, 5, 7);
656+
let builder = ListBuilder::new(|ctx| {
657+
let size = if ctx.index == 0 { 8 } else { 3 };
658+
(TestItem {}, size)
659+
});
660+
let list = ListView::new(builder, 2);
661+
let mut state = ListState::default();
662+
state.select(Some(0));
663+
664+
// Render: shows top 7 rows
665+
let mut buf = Buffer::empty(area);
666+
list.render(area, &mut buf, &mut state);
667+
assert_buffer_eq(
668+
buf,
669+
Buffer::with_lines(vec![
670+
"┌───┐",
671+
"│ │",
672+
"│ │",
673+
"│ │",
674+
"│ │",
675+
"│ │",
676+
"│ │",
677+
]),
678+
);
679+
680+
// next() scrolls within and content_offset becomes 1
681+
state.next();
682+
assert_eq!(state.selected, Some(0));
683+
assert_eq!(state.item_scroll, 1);
684+
685+
// Render at offset 1: shows rows 1-7
686+
let mut buf = Buffer::empty(area);
687+
let builder = ListBuilder::new(|ctx| {
688+
let size = if ctx.index == 0 { 8 } else { 3 };
689+
(TestItem {}, size)
690+
});
691+
let list = ListView::new(builder, 2);
692+
list.render(area, &mut buf, &mut state);
693+
assert_buffer_eq(
694+
buf,
695+
Buffer::with_lines(vec![
696+
"│ │",
697+
"│ │",
698+
"│ │",
699+
"│ │",
700+
"│ │",
701+
"│ │",
702+
"└───┘",
703+
]),
704+
);
705+
706+
// next at max offset jumps to item 1
707+
state.next();
708+
assert_eq!(state.selected, Some(1));
709+
assert_eq!(state.item_scroll, 0);
710+
}
711+
632712
fn assert_buffer_eq(actual: Buffer, expected: Buffer) {
633713
if actual.area != expected.area {
634714
panic!(

0 commit comments

Comments
 (0)