From eb65100e2cf7cae7c7ce763aa1090e94b7b803b7 Mon Sep 17 00:00:00 2001 From: kevinjohna6 <44252850+kevinjohna6@users.noreply.github.com> Date: Fri, 8 Jul 2022 18:09:40 -0700 Subject: [PATCH] feat(widgets/list): allow padding selected using layout constraints Consider padding when choosing offset/scrolling to selected item resolves #328 --- src/layout.rs | 14 ++++ src/widgets/list.rs | 75 ++++++++++++++++++- tests/widgets_list.rs | 167 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 251 insertions(+), 5 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 624c22b9..b415add8 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -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)] diff --git a/src/widgets/list.rs b/src/widgets/list.rs index b0279d7b..0908820f 100644 --- a/src/widgets/list.rs +++ b/src/widgets/list.rs @@ -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, Option), selected: Option, } @@ -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, + bottom_padding_constraint: Option, + ) { + 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, + padding: (Option, Option), 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::() 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::() 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(""); diff --git a/tests/widgets_list.rs b/tests/widgets_list.rs index 019aaf6b..47fddc38 100644 --- a/tests/widgets_list.rs +++ b/tests/widgets_list.rs @@ -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); +}