From 82049aedc03901c6ffa1c944a2a9009eadb5b747 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 12 Jul 2022 19:19:09 +0200 Subject: [PATCH] Add List widget --- src/ui/widgets.rs | 1 + src/ui/widgets/list.rs | 337 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 src/ui/widgets/list.rs diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 63c9092..01269c3 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -1,3 +1,4 @@ +pub mod list; pub mod text; use async_trait::async_trait; diff --git a/src/ui/widgets/list.rs b/src/ui/widgets/list.rs new file mode 100644 index 0000000..78be8a9 --- /dev/null +++ b/src/ui/widgets/list.rs @@ -0,0 +1,337 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use crossterm::style::ContentStyle; +use parking_lot::Mutex; +use toss::frame::{Frame, Pos, Size}; +use toss::styled::Styled; + +use super::Widget; + +/////////// +// State // +/////////// + +#[derive(Debug)] +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)] +struct InnerListState { + rows: Vec>, + offset: usize, + cursor: Option>, + make_cursor_visible: bool, +} + +impl InnerListState { + fn new() -> Self { + Self { + rows: vec![], + offset: 0, + cursor: None, + make_cursor_visible: false, + } + } +} + +impl InnerListState { + fn first_selectable(&self) -> Option> { + self.rows + .iter() + .enumerate() + .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) + } + + fn last_selectable(&self) -> Option> { + self.rows + .iter() + .enumerate() + .rev() + .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) + } + + fn selectable_at_or_before_index(&self, i: usize) -> Option> { + self.rows + .iter() + .enumerate() + .take(i + 1) + .rev() + .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) + } + + fn selectable_at_or_after_index(&self, i: usize) -> Option> { + self.rows + .iter() + .enumerate() + .skip(i) + .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) + } + + fn selectable_before_index(&self, i: usize) -> Option> { + self.rows + .iter() + .enumerate() + .take(i) + .rev() + .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) + } + + fn selectable_after_index(&self, i: usize) -> Option> { + self.rows + .iter() + .enumerate() + .skip(i + 1) + .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) + } + + fn make_cursor_visible(&mut self, height: usize) { + if 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(height); + let max = cursor.idx; + self.offset = self.offset.clamp(min, max); + } + } + + fn clamp_scrolling(&mut self, height: usize) { + let min = 0; + let max = self.rows.len().saturating_sub(height); + self.offset = self.offset.clamp(min, max); + } +} + +impl InnerListState { + fn focusing(&self, id: &Id) -> bool { + if let Some(cursor) = &self.cursor { + cursor.id == *id + } else { + false + } + } +} + +impl InnerListState { + fn selectable_of_id(&self, id: &Id) -> Option> { + self.rows.iter().enumerate().find_map(|(i, r)| match r { + Some(rid) if rid == id => Some(Cursor::new(id.clone(), i)), + _ => None, + }) + } + + fn fix_cursor(&mut self) { + self.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() + } + } + + /// Bring the list into a state consistent with the current rows and height. + fn stabilize(&mut self, rows: &[Row], height: usize) { + self.rows = rows.iter().map(|r| r.id().cloned()).collect(); + + self.fix_cursor(); + if self.make_cursor_visible { + self.make_cursor_visible(height); + self.make_cursor_visible = false; + } + + self.clamp_scrolling(height); + } +} + +pub struct ListState(Arc>>); + +impl ListState { + pub fn new() -> Self { + Self(Arc::new(Mutex::new(InnerListState::new()))) + } + + pub fn list(&self) -> List { + List::new(self.0.clone()) + } + + pub fn scroll_up(&mut self) { + let mut guard = self.0.lock(); + guard.offset = guard.offset.saturating_sub(1); + } + + pub fn scroll_down(&mut self) { + let mut guard = self.0.lock(); + guard.offset = guard.offset.saturating_add(1); + } +} + +impl ListState { + pub fn cursor(&self) -> Option { + self.0.lock().cursor.as_ref().map(|c| c.id.clone()) + } + + pub fn move_cursor_up(&mut self) { + let mut guard = self.0.lock(); + if let Some(cursor) = &guard.cursor { + if let Some(new_cursor) = guard.selectable_before_index(cursor.idx) { + guard.cursor = Some(new_cursor); + guard.make_cursor_visible = true; + } + } + } + + pub fn move_cursor_down(&mut self) { + let mut guard = self.0.lock(); + if let Some(cursor) = &guard.cursor { + if let Some(new_cursor) = guard.selectable_after_index(cursor.idx) { + guard.cursor = Some(new_cursor); + guard.make_cursor_visible = true; + } + } + } +} + +//////////// +// Widget // +//////////// + +// TODO Use widgets for rows +#[derive(Debug)] +enum Row { + Unselectable(Styled), + Selectable { + id: Id, + normal: Styled, + normal_bg: ContentStyle, + selected: Styled, + selected_bg: ContentStyle, + }, +} + +impl Row { + fn id(&self) -> Option<&Id> { + match self { + Row::Unselectable(_) => None, + Row::Selectable { id, .. } => Some(id), + } + } + + fn styled(&self) -> &Styled { + match self { + Row::Unselectable(styled) => styled, + Row::Selectable { normal, .. } => normal, + } + } +} + +pub struct List { + state: Arc>>, + rows: Vec>, + focus: bool, +} + +impl List { + fn new(state: Arc>>) -> Self { + Self { + state, + rows: vec![], + focus: false, + } + } + + pub fn focus(mut self, focus: bool) -> Self { + self.focus = focus; + self + } + + pub fn add_unsel>(&mut self, styled: S) { + self.rows.push(Row::Unselectable(styled.into())); + } + + pub fn add_sel( + &mut self, + id: Id, + normal: S1, + normal_bg: ContentStyle, + selected: S2, + selected_bg: ContentStyle, + ) where + S1: Into, + S2: Into, + { + self.rows.push(Row::Selectable { + id, + normal: normal.into(), + normal_bg, + selected: selected.into(), + selected_bg, + }); + } +} + +#[async_trait] +impl Widget for List { + fn size(&self, frame: &mut Frame, _max_width: Option, _max_height: Option) -> Size { + let width = self + .rows + .iter() + .map(|r| frame.width_styled(r.styled())) + .max() + .unwrap_or(0); + let height = self.rows.len(); + Size::new(width as u16, height as u16) + } + + async fn render(self, frame: &mut Frame, pos: Pos, size: Size) { + let mut guard = self.state.lock(); + guard.stabilize(&self.rows, size.height.into()); + for (i, row) in self.rows.into_iter().enumerate() { + let dy = i as i32 - guard.offset as i32; + if dy < 0 || dy >= size.height as i32 { + break; + } + + let pos = pos + Pos::new(0, dy); + match row { + Row::Unselectable(styled) => frame.write(pos, styled), + Row::Selectable { + id, + normal, + normal_bg, + selected, + selected_bg, + } => { + let (fg, bg) = if self.focus && guard.focusing(&id) { + (selected, selected_bg) + } else { + (normal, normal_bg) + }; + frame.write(pos, (" ".repeat(size.width.into()), bg)); + frame.write(pos, fg); + } + } + } + } +}