diff --git a/src/ui/widgets2.rs b/src/ui/widgets2.rs index a12a5aa..aed063a 100644 --- a/src/ui/widgets2.rs +++ b/src/ui/widgets2.rs @@ -1,3 +1,5 @@ +mod list; mod popup; +pub use self::list::*; pub use self::popup::*; diff --git a/src/ui/widgets2/list.rs b/src/ui/widgets2/list.rs new file mode 100644 index 0000000..5693a8a --- /dev/null +++ b/src/ui/widgets2/list.rs @@ -0,0 +1,328 @@ +use std::vec; + +use async_trait::async_trait; +use toss::{AsyncWidget, Frame, Pos, Size, WidthDb}; + +#[derive(Debug, Clone)] +struct Cursor { + /// Id of the element the cursor is pointing to. + /// + /// If the rows change (e.g. reorder) but there is still a row with this id, + /// the cursor is moved to this row. + id: Id, + + /// Index of the row the cursor is pointing to. + /// + /// If the rows change and there is no longer a row with the cursor's id, + /// the cursor is moved up or down to the next selectable row. This way, it + /// stays close to its previous position. + idx: usize, +} + +impl Cursor { + pub fn new(id: Id, idx: usize) -> Self { + Self { id, idx } + } +} + +#[derive(Debug)] +pub struct ListState { + /// Amount of lines that the list is scrolled, i.e. offset from the top. + offset: usize, + + /// A cursor within the list. + /// + /// Set to `None` if the list contains no selectable rows. + cursor: Option>, + + /// Height of the list when it was last rendered. + last_height: u16, + + /// Rows when the list was last rendered. + last_rows: Vec>, +} + +impl ListState { + pub fn new() -> Self { + Self { + offset: 0, + cursor: None, + last_height: 0, + last_rows: vec![], + } + } + + pub fn selected(&self) -> Option<&Id> { + self.cursor.as_ref().map(|cursor| &cursor.id) + } +} + +impl ListState { + fn first_selectable(&self) -> Option> { + self.last_rows + .iter() + .enumerate() + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn last_selectable(&self) -> Option> { + self.last_rows + .iter() + .enumerate() + .rev() + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn selectable_at_or_before_index(&self, i: usize) -> Option> { + self.last_rows + .iter() + .enumerate() + .take(i + 1) + .rev() + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn selectable_at_or_after_index(&self, i: usize) -> Option> { + self.last_rows + .iter() + .enumerate() + .skip(i) + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn selectable_before_index(&self, i: usize) -> Option> { + self.last_rows + .iter() + .enumerate() + .take(i) + .rev() + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn selectable_after_index(&self, i: usize) -> Option> { + self.last_rows + .iter() + .enumerate() + .skip(i + 1) + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn move_cursor_to_make_it_visible(&mut self) { + if let Some(cursor) = &self.cursor { + let first_visible_line_idx = self.offset; + let last_visible_line_idx = self + .offset + .saturating_add(self.last_height.into()) + .saturating_sub(1); + + let new_cursor = if cursor.idx < first_visible_line_idx { + self.selectable_at_or_after_index(first_visible_line_idx) + } else if cursor.idx > last_visible_line_idx { + self.selectable_at_or_before_index(last_visible_line_idx) + } else { + return; + }; + + if let Some(new_cursor) = new_cursor { + self.cursor = Some(new_cursor); + } + } + } + + fn scroll_so_cursor_is_visible(&mut self) { + if self.last_height == 0 { + // Cursor can't be visible because nothing is visible + return; + } + + if let Some(cursor) = &self.cursor { + // As long as height > 0, min <= max is true + let min = (cursor.idx + 1).saturating_sub(self.last_height.into()); + let max = cursor.idx; // Rows have a height of 1 + self.offset = self.offset.clamp(min, max); + } + } + + fn clamp_scrolling(&mut self) { + let min = 0; + let max = self.last_rows.len().saturating_sub(self.last_height.into()); + self.offset = self.offset.clamp(min, max); + } + + fn scroll_to(&mut self, new_offset: usize) { + self.offset = new_offset; + self.clamp_scrolling(); + self.move_cursor_to_make_it_visible(); + } + + fn move_cursor_to(&mut self, new_cursor: Cursor) { + self.cursor = Some(new_cursor); + self.scroll_so_cursor_is_visible(); + self.clamp_scrolling(); + } + + /// Scroll the list up by an amount of lines. + pub fn scroll_up(&mut self, lines: usize) { + self.scroll_to(self.offset.saturating_sub(lines)); + } + + /// Scroll the list down by an amount of lines. + pub fn scroll_down(&mut self, lines: usize) { + self.scroll_to(self.offset.saturating_add(lines)); + } + + /// Scroll so that the cursor is in the center of the widget, or at least as + /// close as possible. + pub fn center_cursor(&mut self) { + if let Some(cursor) = &self.cursor { + let height: usize = self.last_height.into(); + self.scroll_to(cursor.idx.saturating_sub(height / 2)); + } + } + + /// Move the cursor up to the next selectable row. + pub fn move_cursor_up(&mut self) { + if let Some(cursor) = &self.cursor { + if let Some(new_cursor) = self.selectable_before_index(cursor.idx) { + self.move_cursor_to(new_cursor); + } + } + } + + /// Move the cursor down to the next selectable row. + pub fn move_cursor_down(&mut self) { + if let Some(cursor) = &self.cursor { + if let Some(new_cursor) = self.selectable_after_index(cursor.idx) { + self.move_cursor_to(new_cursor); + } + } + } + + /// Move the cursor to the first selectable row. + pub fn move_cursor_to_top(&mut self) { + if let Some(new_cursor) = self.first_selectable() { + self.move_cursor_to(new_cursor); + } + } + + /// Move the cursor to the last selectable row. + pub fn move_cursor_to_bottom(&mut self) { + if let Some(new_cursor) = self.last_selectable() { + self.move_cursor_to(new_cursor); + } + } + + pub fn widget(&mut self) -> List<'_, Id, W> { + List { + state: self, + rows: vec![], + } + } +} + +impl ListState { + fn selectable_of_id(&self, id: &Id) -> Option> { + self.last_rows + .iter() + .enumerate() + .find_map(|(i, row)| match row { + Some(rid) if rid == id => Some(Cursor::new(rid.clone(), i)), + _ => None, + }) + } + + fn fix_cursor(&mut self) { + let new_cursor = if let Some(cursor) = &self.cursor { + self.selectable_of_id(&cursor.id) + .or_else(|| self.selectable_at_or_before_index(cursor.idx)) + .or_else(|| self.selectable_at_or_after_index(cursor.idx)) + } else { + self.first_selectable() + }; + + if let Some(new_cursor) = new_cursor { + self.move_cursor_to(new_cursor); + } else { + self.cursor = None; + } + } +} + +struct Row { + id: Option, + widget: W, +} + +pub struct List<'a, Id, W> { + state: &'a mut ListState, + rows: Vec>, +} + +impl List<'_, Id, W> { + pub fn state(&self) -> &ListState { + &self.state + } + + pub fn state_mut(&mut self) -> &mut ListState { + &mut self.state + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + pub fn add_unsel(&mut self, widget: W) { + self.rows.push(Row { id: None, widget }); + } + + pub fn add_sel(&mut self, id: Id, widget: W) { + self.rows.push(Row { + id: Some(id), + widget, + }); + } +} + +#[async_trait] +impl AsyncWidget for List<'_, Id, W> +where + Id: Clone + Eq + Send + Sync, + W: AsyncWidget + Send + Sync, +{ + async fn size( + &self, + widthdb: &mut WidthDb, + max_width: Option, + _max_height: Option, + ) -> Result { + let mut width = 0; + for row in &self.rows { + let size = row.widget.size(widthdb, max_width, Some(1)).await?; + width = width.max(size.width); + } + let height = self.rows.len().try_into().unwrap_or(u16::MAX); + Ok(Size::new(width, height)) + } + + async fn draw(self, frame: &mut Frame) -> Result<(), E> { + let size = frame.size(); + + self.state.last_rows = self.rows.iter().map(|row| row.id.clone()).collect(); + self.state.last_height = size.height; + self.state.fix_cursor(); + + for (y, row) in self + .rows + .into_iter() + .skip(self.state.offset) + .take(size.height.into()) + .enumerate() + { + frame.push(Pos::new(0, y as i32), Size::new(size.width, 1)); + row.widget.draw(frame).await?; + frame.pop(); + } + + Ok(()) + } +}