Skip to content

Commit

Permalink
feat(widgets/list): allow padding selected using layout constraints
Browse files Browse the repository at this point in the history
Consider padding when choosing offset/scrolling to selected item
resolves fdehau#328
  • Loading branch information
kevinjohna6 committed Jul 9, 2022
1 parent a6b25a4 commit c9b75ed
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 5 deletions.
14 changes: 14 additions & 0 deletions src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
77 changes: 73 additions & 4 deletions src/widgets/list.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand All @@ -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>,
}

Expand All @@ -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)]
Expand Down Expand Up @@ -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) {
Expand All @@ -147,15 +178,52 @@ 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 {
height = height.saturating_sub(self.items[start].height());
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 {
Expand Down Expand Up @@ -190,7 +258,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("");
Expand Down
167 changes: 166 additions & 1 deletion tests/widgets_list.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use tui::{
backend::TestBackend,
buffer::Buffer,
layout::Rect,
layout::{Constraint, Rect},
style::{Color, Style},
symbols,
text::Spans,
Expand Down Expand Up @@ -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);
}

0 comments on commit c9b75ed

Please sign in to comment.