feat(widgets/list): allow padding selected using layout constraints

Consider padding when choosing offset/scrolling to selected item
resolves #328
This commit is contained in:
kevinjohna6 2022-07-08 18:09:40 -07:00
parent a6b25a4877
commit eb65100e2c
3 changed files with 251 additions and 5 deletions

View file

@ -43,6 +43,20 @@ impl Constraint {
Constraint::Min(m) => length.max(m),
}
}
/// returns the new target padding based on the constraint
pub fn apply_for_padding(&self, total_length: u16, current_padding: u16) -> u16 {
match *self {
Constraint::Percentage(p) => total_length * p / 100,
Constraint::Ratio(num, den) => {
let r = num * u32::from(total_length) / den;
r as u16
}
Constraint::Length(l) => total_length.min(l),
Constraint::Max(m) => current_padding.min(m),
Constraint::Min(m) => current_padding.max(m),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]

View file

@ -1,6 +1,8 @@
use std::cmp::Ordering;
use crate::{
buffer::Buffer,
layout::{Corner, Rect},
layout::{Constraint, Corner, Rect},
style::Style,
text::Text,
widgets::{Block, StatefulWidget, Widget},
@ -10,6 +12,7 @@ use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Default)]
pub struct ListState {
offset: usize,
padding: (Option<Constraint>, Option<Constraint>),
selected: Option<usize>,
}
@ -24,6 +27,33 @@ impl ListState {
self.offset = 0;
}
}
/// Apply padding when scrolling selected item into view.
///
/// The scrolling offset algorithm prioritizes `top_padding_constraint` over `bottom_padding_constraint`.
///
/// # Examples
///
/// ```
/// use tui::layout::Constraint;
/// let mut state = tui::widgets::ListState::default();
/// state.padding(
/// Some(Constraint::Percentage(50)),
/// Some(Constraint::Percentage(50)),
/// );
/// // or
/// state.padding(None, Some(Constraint::Length(3)));
/// // or
/// state.padding(Some(Constraint::Max(6)), Some(Constraint::Min(3)));
/// // etc.
/// ```
pub fn padding(
&mut self,
top_padding_constraint: Option<Constraint>,
bottom_padding_constraint: Option<Constraint>,
) {
self.padding = (top_padding_constraint, bottom_padding_constraint);
}
}
#[derive(Debug, Clone, PartialEq)]
@ -131,6 +161,7 @@ impl<'a> List<'a> {
fn get_items_bounds(
&self,
selected: Option<usize>,
padding: (Option<Constraint>, Option<Constraint>),
offset: usize,
max_height: usize,
) -> (usize, usize) {
@ -147,7 +178,42 @@ impl<'a> List<'a> {
}
let selected = selected.unwrap_or(0).min(self.items.len() - 1);
while selected >= end {
// This function prioritizes the ideal start padding to the ideal end padding
let padding_cmp_ideal = |start: usize, end: usize| {
let end_cmp_ideal = padding
.1
.map(|c| {
let current_padding = self
.items
.get((selected + 1)..end)
.map(|ir| ir.iter().map(|i| i.height()).sum::<usize>() as u16)
.unwrap_or(0);
current_padding.cmp(&c.apply_for_padding(max_height as u16, current_padding))
})
.unwrap_or(Ordering::Equal);
let start_cmp_ideal = padding
.0
.map(|c| {
let current_padding = self
.items
.get(start..selected)
.map(|ir| ir.iter().map(|i| i.height()).sum::<usize>() as u16)
.unwrap_or(0);
current_padding.cmp(&c.apply_for_padding(max_height as u16, current_padding))
})
.unwrap_or(Ordering::Equal);
if start_cmp_ideal == Ordering::Equal {
end_cmp_ideal.reverse()
} else {
start_cmp_ideal
}
};
while selected >= end
|| (padding_cmp_ideal(start, end) == Ordering::Greater && end < self.items.len())
{
height = height.saturating_add(self.items[end].height());
end += 1;
while height > max_height {
@ -155,7 +221,7 @@ impl<'a> List<'a> {
start += 1;
}
}
while selected < start {
while selected < start || (padding_cmp_ideal(start, end) == Ordering::Less && start > 0) {
start -= 1;
height = height.saturating_add(self.items[start].height());
while height > max_height {
@ -190,7 +256,8 @@ impl<'a> StatefulWidget for List<'a> {
}
let list_height = list_area.height as usize;
let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
let (start, end) =
self.get_items_bounds(state.selected, state.padding, state.offset, list_height);
state.offset = start;
let highlight_symbol = self.highlight_symbol.unwrap_or("");

View file

@ -1,7 +1,7 @@
use tui::{
backend::TestBackend,
buffer::Buffer,
layout::Rect,
layout::{Constraint, Rect},
style::{Color, Style},
symbols,
text::Spans,
@ -198,3 +198,168 @@ fn widgets_list_should_repeat_highlight_symbol() {
}
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_list_should_respect_padding() {
let backend = TestBackend::new(10, 4);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = ListState::default();
state.select(Some(4));
state.padding(None, Some(Constraint::Percentage(50)));
terminal
.draw(|f| {
let size = f.size();
let items = vec![
ListItem::new("Item 0"),
ListItem::new("Item 1"),
ListItem::new("Item 2"),
ListItem::new("Item 3"),
ListItem::new("Item 4"),
ListItem::new("Item 5"),
ListItem::new("Item 6"),
ListItem::new("Item 7"),
];
let list = List::new(items)
.highlight_symbol(">> ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, size, &mut state);
})
.unwrap();
let expected = Buffer::with_lines(vec![" Item 3 ", ">> Item 4 ", " Item 5 ", " Item 6 "]);
terminal.backend().assert_buffer(&expected);
state.padding(
Some(Constraint::Percentage(50)),
Some(Constraint::Percentage(50)),
);
terminal
.draw(|f| {
let size = f.size();
let items = vec![
ListItem::new("Item 0"),
ListItem::new("Item 1"),
ListItem::new("Item 2"),
ListItem::new("Item 3"),
ListItem::new("Item 4"),
ListItem::new("Item 5"),
ListItem::new("Item 6"),
ListItem::new("Item 7"),
];
let list = List::new(items)
.highlight_symbol(">> ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, size, &mut state);
})
.unwrap();
let expected = Buffer::with_lines(vec![" Item 2 ", " Item 3 ", ">> Item 4 ", " Item 5 "]);
terminal.backend().assert_buffer(&expected);
state.padding(Some(Constraint::Max(1)), None);
terminal
.draw(|f| {
let size = f.size();
let items = vec![
ListItem::new("Item 0"),
ListItem::new("Item 1"),
ListItem::new("Item 2"),
ListItem::new("Item 3"),
ListItem::new("Item 4"),
ListItem::new("Item 5"),
ListItem::new("Item 6"),
ListItem::new("Item 7"),
];
let list = List::new(items)
.highlight_symbol(">> ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, size, &mut state);
})
.unwrap();
let expected = Buffer::with_lines(vec![" Item 3 ", ">> Item 4 ", " Item 5 ", " Item 6 "]);
terminal.backend().assert_buffer(&expected);
//Prefers top padding to bottom padding
state.padding(Some(Constraint::Length(3)), Some(Constraint::Length(3)));
terminal
.draw(|f| {
let size = f.size();
let items = vec![
ListItem::new("Item 0"),
ListItem::new("Item 1"),
ListItem::new("Item 2"),
ListItem::new("Item 3"),
ListItem::new("Item 4"),
ListItem::new("Item 5"),
ListItem::new("Item 6"),
ListItem::new("Item 7"),
];
let list = List::new(items)
.highlight_symbol(">> ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, size, &mut state);
})
.unwrap();
let expected = Buffer::with_lines(vec![" Item 1 ", " Item 2 ", " Item 3 ", ">> Item 4 "]);
terminal.backend().assert_buffer(&expected);
}
#[test]
fn widgets_list_padding_doesnt_panic_for_offscreen_offset() {
let backend = TestBackend::new(10, 1);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = ListState::default();
state.select(Some(7));
state.padding(
Some(Constraint::Length(0)),
Some(Constraint::Percentage(100)),
);
terminal
.draw(|f| {
let size = f.size();
let items = vec![
ListItem::new("Item 0"),
ListItem::new("Item 1"),
ListItem::new("Item 2"),
ListItem::new("Item 3"),
ListItem::new("Item 4"),
ListItem::new("Item 5"),
ListItem::new("Item 6"),
ListItem::new("Item 7"),
];
let list = List::new(items)
.highlight_symbol(">> ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, size, &mut state);
})
.unwrap();
let expected = Buffer::with_lines(vec![">> Item 7 "]);
terminal.backend().assert_buffer(&expected);
// now the state.offset is set to 7
// we set state.selected to 1
// and we just check that it doesnt cause a panic in the padding code
state.select(Some(1));
terminal
.draw(|f| {
let size = f.size();
let items = vec![
ListItem::new("Item 0"),
ListItem::new("Item 1"),
ListItem::new("Item 2"),
ListItem::new("Item 3"),
ListItem::new("Item 4"),
ListItem::new("Item 5"),
ListItem::new("Item 6"),
ListItem::new("Item 7"),
];
let list = List::new(items)
.highlight_symbol(">> ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, size, &mut state);
})
.unwrap();
let expected = Buffer::with_lines(vec![">> Item 1 "]);
terminal.backend().assert_buffer(&expected);
}