summaryrefslogtreecommitdiff
path: root/src/list/state.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/list/state.rs')
-rw-r--r--src/list/state.rs584
1 files changed, 361 insertions, 223 deletions
diff --git a/src/list/state.rs b/src/list/state.rs
index 48a404f..25ca1de 100644
--- a/src/list/state.rs
+++ b/src/list/state.rs
@@ -1,14 +1,35 @@
use anyhow::{Context, Result};
-use ratatui::{
- layout::{Constraint, Rect},
- style::{Style, Stylize},
- text::{Span, Text},
- widgets::{Block, Borders, HighlightSpacing, Paragraph, Row, Table, TableState},
- Frame,
+use crossterm::{
+ cursor::{MoveTo, MoveToNextLine},
+ style::{Attribute, Color, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor},
+ terminal::{self, BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate},
+ QueueableCommand,
};
-use std::{fmt::Write, mem};
+use std::{
+ fmt::Write as _,
+ io::{self, StdoutLock, Write},
+};
+
+use crate::{app_state::AppState, exercise::Exercise, term::progress_bar, MAX_EXERCISE_NAME_LEN};
+
+const MAX_SCROLL_PADDING: usize = 8;
+// +1 for column padding.
+const SPACE: &[u8] = &[b' '; MAX_EXERCISE_NAME_LEN + 1];
+
+fn next_ln(stdout: &mut StdoutLock) -> io::Result<()> {
+ stdout
+ .queue(Clear(ClearType::UntilNewLine))?
+ .queue(MoveToNextLine(1))?;
+ Ok(())
+}
-use crate::{app_state::AppState, progress_bar::progress_bar_ratatui};
+// Avoids having the last written char as the last displayed one when the
+// written width is higher than the terminal width.
+// Happens on the Gnome terminal for example.
+fn next_ln_overwrite(stdout: &mut StdoutLock) -> io::Result<()> {
+ stdout.write_all(b" ")?;
+ next_ln(stdout)
+}
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Filter {
@@ -17,269 +38,386 @@ pub enum Filter {
None,
}
-pub struct UiState<'a> {
- pub table: Table<'static>,
+pub struct ListState<'a> {
+ /// Footer message to be displayed if not empty.
pub message: String,
- pub filter: Filter,
app_state: &'a mut AppState,
- table_state: TableState,
- n_rows: usize,
+ name_col_width: usize,
+ filter: Filter,
+ n_rows_with_filter: usize,
+ /// Selected row out of the filtered ones.
+ selected_row: Option<usize>,
+ row_offset: usize,
+ term_width: u16,
+ term_height: u16,
+ separator_line: Vec<u8>,
+ narrow_term: bool,
+ show_footer: bool,
+ max_n_rows_to_display: usize,
+ scroll_padding: usize,
}
-impl<'a> UiState<'a> {
- pub fn with_updated_rows(mut self) -> Self {
- let current_exercise_ind = self.app_state.current_exercise_ind();
+impl<'a> ListState<'a> {
+ pub fn new(app_state: &'a mut AppState, stdout: &mut StdoutLock) -> io::Result<Self> {
+ stdout.queue(Clear(ClearType::All))?;
- self.n_rows = 0;
- let rows = self
- .app_state
+ let name_col_title_len = 4;
+ let name_col_width = app_state
.exercises()
.iter()
- .enumerate()
- .filter_map(|(ind, exercise)| {
- let exercise_state = if exercise.done {
- if self.filter == Filter::Pending {
- return None;
- }
-
- "DONE".green()
- } else {
- if self.filter == Filter::Done {
- return None;
- }
+ .map(|exercise| exercise.name.len())
+ .max()
+ .map_or(name_col_title_len, |max| max.max(name_col_title_len));
- "PENDING".yellow()
- };
+ let filter = Filter::None;
+ let n_rows_with_filter = app_state.exercises().len();
+ let selected = app_state.current_exercise_ind();
- self.n_rows += 1;
+ let mut slf = Self {
+ message: String::with_capacity(128),
+ app_state,
+ name_col_width,
+ filter,
+ n_rows_with_filter,
+ selected_row: Some(selected),
+ row_offset: selected.saturating_sub(MAX_SCROLL_PADDING),
+ // Set by `set_term_size`
+ term_width: 0,
+ term_height: 0,
+ separator_line: Vec::new(),
+ narrow_term: false,
+ show_footer: true,
+ max_n_rows_to_display: 0,
+ scroll_padding: 0,
+ };
- let next = if ind == current_exercise_ind {
- ">>>>".bold().red()
- } else {
- Span::default()
- };
-
- Some(Row::new([
- next,
- exercise_state,
- Span::raw(exercise.name),
- Span::raw(exercise.path),
- ]))
- });
-
- self.table = self.table.rows(rows);
-
- if self.n_rows == 0 {
- self.table_state.select(None);
- } else {
- self.table_state.select(Some(
- self.table_state
- .selected()
- .map_or(0, |selected| selected.min(self.n_rows - 1)),
- ));
- }
+ let (width, height) = terminal::size()?;
+ slf.set_term_size(width, height);
+ slf.draw(stdout)?;
- self
+ Ok(slf)
}
- pub fn new(app_state: &'a mut AppState) -> Self {
- let header = Row::new(["Next", "State", "Name", "Path"]);
+ fn update_offset(&mut self) {
+ let Some(selected) = self.selected_row else {
+ return;
+ };
- let max_name_len = app_state
- .exercises()
- .iter()
- .map(|exercise| exercise.name.len())
- .max()
- .unwrap_or(4) as u16;
-
- let widths = [
- Constraint::Length(4),
- Constraint::Length(7),
- Constraint::Length(max_name_len),
- Constraint::Fill(1),
- ];
-
- let table = Table::default()
- .widths(widths)
- .header(header)
- .column_spacing(2)
- .highlight_spacing(HighlightSpacing::Always)
- .highlight_style(Style::new().bg(ratatui::style::Color::Rgb(50, 50, 50)))
- .highlight_symbol("🦀")
- .block(Block::default().borders(Borders::BOTTOM));
+ let min_offset = (selected + self.scroll_padding)
+ .saturating_sub(self.max_n_rows_to_display.saturating_sub(1));
+ let max_offset = selected.saturating_sub(self.scroll_padding);
+ let global_max_offset = self
+ .n_rows_with_filter
+ .saturating_sub(self.max_n_rows_to_display);
+
+ self.row_offset = self
+ .row_offset
+ .max(min_offset)
+ .min(max_offset)
+ .min(global_max_offset);
+ }
- let selected = app_state.current_exercise_ind();
- let table_state = TableState::default()
- .with_offset(selected.saturating_sub(10))
- .with_selected(Some(selected));
+ pub fn set_term_size(&mut self, width: u16, height: u16) {
+ self.term_width = width;
+ self.term_height = height;
- let filter = Filter::None;
- let n_rows = app_state.exercises().len();
+ if height == 0 {
+ return;
+ }
- let slf = Self {
- table,
- message: String::with_capacity(128),
- filter,
- app_state,
- table_state,
- n_rows,
- };
+ let wide_help_footer_width = 95;
+ // The help footer is shorter when nothing is selected.
+ self.narrow_term = width < wide_help_footer_width && self.selected_row.is_some();
- slf.with_updated_rows()
- }
+ let header_height = 1;
+ // 2 separator, 1 progress bar, 1-2 footer message.
+ let footer_height = 4 + u16::from(self.narrow_term);
+ self.show_footer = height > header_height + footer_height;
- pub fn select_next(&mut self) {
- if self.n_rows > 0 {
- let next = self
- .table_state
- .selected()
- .map_or(0, |selected| (selected + 1).min(self.n_rows - 1));
- self.table_state.select(Some(next));
+ if self.show_footer {
+ self.separator_line = "─".as_bytes().repeat(width as usize);
}
+
+ self.max_n_rows_to_display = height
+ .saturating_sub(header_height + u16::from(self.show_footer) * footer_height)
+ as usize;
+
+ self.scroll_padding = (self.max_n_rows_to_display / 4).min(MAX_SCROLL_PADDING);
+
+ self.update_offset();
}
- pub fn select_previous(&mut self) {
- if self.n_rows > 0 {
- let previous = self
- .table_state
- .selected()
- .map_or(0, |selected| selected.saturating_sub(1));
- self.table_state.select(Some(previous));
+ fn draw_rows(
+ &self,
+ stdout: &mut StdoutLock,
+ filtered_exercises: impl Iterator<Item = (usize, &'a Exercise)>,
+ ) -> io::Result<usize> {
+ let current_exercise_ind = self.app_state.current_exercise_ind();
+ let mut n_displayed_rows = 0;
+
+ for (exercise_ind, exercise) in filtered_exercises
+ .skip(self.row_offset)
+ .take(self.max_n_rows_to_display)
+ {
+ if self.selected_row == Some(self.row_offset + n_displayed_rows) {
+ stdout.queue(SetBackgroundColor(Color::Rgb {
+ r: 50,
+ g: 50,
+ b: 50,
+ }))?;
+ stdout.write_all("🦀".as_bytes())?;
+ } else {
+ stdout.write_all(b" ")?;
+ }
+
+ if exercise_ind == current_exercise_ind {
+ stdout.queue(SetForegroundColor(Color::Red))?;
+ stdout.write_all(b">>>>>>> ")?;
+ } else {
+ stdout.write_all(b" ")?;
+ }
+
+ if exercise.done {
+ stdout.queue(SetForegroundColor(Color::Yellow))?;
+ stdout.write_all(b"DONE ")?;
+ } else {
+ stdout.queue(SetForegroundColor(Color::Green))?;
+ stdout.write_all(b"PENDING ")?;
+ }
+
+ stdout.queue(SetForegroundColor(Color::Reset))?;
+
+ stdout.write_all(exercise.name.as_bytes())?;
+ stdout.write_all(&SPACE[..self.name_col_width + 2 - exercise.name.len()])?;
+
+ stdout.write_all(exercise.path.as_bytes())?;
+
+ next_ln_overwrite(stdout)?;
+ stdout.queue(ResetColor)?;
+ n_displayed_rows += 1;
}
+
+ Ok(n_displayed_rows)
}
- pub fn select_first(&mut self) {
- if self.n_rows > 0 {
- self.table_state.select(Some(0));
+ pub fn draw(&mut self, stdout: &mut StdoutLock) -> io::Result<()> {
+ if self.term_height == 0 {
+ return Ok(());
}
- }
- pub fn select_last(&mut self) {
- if self.n_rows > 0 {
- self.table_state.select(Some(self.n_rows - 1));
+ stdout.queue(BeginSynchronizedUpdate)?.queue(MoveTo(0, 0))?;
+
+ // Header
+ stdout.write_all(b" Current State Name")?;
+ stdout.write_all(&SPACE[..self.name_col_width - 2])?;
+ stdout.write_all(b"Path")?;
+ next_ln_overwrite(stdout)?;
+
+ // Rows
+ let iter = self.app_state.exercises().iter().enumerate();
+ let n_displayed_rows = match self.filter {
+ Filter::Done => self.draw_rows(stdout, iter.filter(|(_, exercise)| exercise.done))?,
+ Filter::Pending => {
+ self.draw_rows(stdout, iter.filter(|(_, exercise)| !exercise.done))?
+ }
+ Filter::None => self.draw_rows(stdout, iter)?,
+ };
+
+ for _ in 0..self.max_n_rows_to_display - n_displayed_rows {
+ next_ln(stdout)?;
}
- }
- pub fn draw(&mut self, frame: &mut Frame) -> Result<()> {
- let area = frame.area();
- let narrow = area.width < 95;
- let narrow_u16 = u16::from(narrow);
- let table_height = area.height - 3 - narrow_u16;
-
- frame.render_stateful_widget(
- &self.table,
- Rect {
- x: 0,
- y: 0,
- width: area.width,
- height: table_height,
- },
- &mut self.table_state,
- );
+ if self.show_footer {
+ stdout.write_all(&self.separator_line)?;
+ next_ln(stdout)?;
- frame.render_widget(
- Paragraph::new(progress_bar_ratatui(
+ progress_bar(
+ stdout,
self.app_state.n_done(),
self.app_state.exercises().len() as u16,
- area.width,
- )?)
- .block(Block::default().borders(Borders::BOTTOM)),
- Rect {
- x: 0,
- y: table_height,
- width: area.width,
- height: 2,
- },
- );
+ self.term_width,
+ )?;
+ next_ln(stdout)?;
+
+ stdout.write_all(&self.separator_line)?;
+ next_ln(stdout)?;
+
+ if self.message.is_empty() {
+ // Help footer message
+ if self.selected_row.is_some() {
+ stdout.write_all(
+ "↓/j ↑/k home/g end/G | <c>ontinue at | <r>eset exercise".as_bytes(),
+ )?;
+ if self.narrow_term {
+ next_ln_overwrite(stdout)?;
+ stdout.write_all(b"filter ")?;
+ } else {
+ stdout.write_all(b" | filter ")?;
+ }
+ } else {
+ // Nothing selected (and nothing shown), so only display filter and quit.
+ stdout.write_all(b"filter ")?;
+ }
- let message = if self.message.is_empty() {
- // Help footer.
- let mut text = Text::default();
- let mut spans = Vec::with_capacity(4);
- spans.push(Span::raw(
- "↓/j ↑/k home/g end/G │ <c>ontinue at │ <r>eset exercise │",
- ));
-
- if narrow {
- text.push_line(mem::take(&mut spans));
- spans.push(Span::raw("filter "));
- } else {
- spans.push(Span::raw(" filter "));
- }
+ match self.filter {
+ Filter::Done => {
+ stdout
+ .queue(SetForegroundColor(Color::Magenta))?
+ .queue(SetAttribute(Attribute::Underlined))?;
+ stdout.write_all(b"<d>one")?;
+ stdout.queue(ResetColor)?;
+ stdout.write_all(b"/<p>ending")?;
+ }
+ Filter::Pending => {
+ stdout.write_all(b"<d>one/")?;
+ stdout
+ .queue(SetForegroundColor(Color::Magenta))?
+ .queue(SetAttribute(Attribute::Underlined))?;
+ stdout.write_all(b"<p>ending")?;
+ stdout.queue(ResetColor)?;
+ }
+ Filter::None => stdout.write_all(b"<d>one/<p>ending")?,
+ }
- match self.filter {
- Filter::Done => {
- spans.push("<d>one".underlined().magenta());
- spans.push(Span::raw("/<p>ending"));
+ stdout.write_all(b" | <q>uit list")?;
+
+ if self.narrow_term {
+ next_ln_overwrite(stdout)?;
+ } else {
+ next_ln(stdout)?;
}
- Filter::Pending => {
- spans.push(Span::raw("<d>one/"));
- spans.push("<p>ending".underlined().magenta());
+ } else {
+ stdout.queue(SetForegroundColor(Color::Magenta))?;
+ stdout.write_all(self.message.as_bytes())?;
+ stdout.queue(ResetColor)?;
+ next_ln_overwrite(stdout)?;
+ if self.narrow_term {
+ next_ln(stdout)?;
}
- Filter::None => spans.push(Span::raw("<d>one/<p>ending")),
}
+ }
+
+ stdout.queue(EndSynchronizedUpdate)?.flush()
+ }
+
+ fn set_selected(&mut self, selected: usize) {
+ self.selected_row = Some(selected);
+ self.update_offset();
+ }
- spans.push(Span::raw(" │ <q>uit list"));
- text.push_line(spans);
- text
- } else {
- Text::from(self.message.as_str().light_blue())
+ fn update_rows(&mut self) {
+ self.n_rows_with_filter = match self.filter {
+ Filter::Done => self
+ .app_state
+ .exercises()
+ .iter()
+ .filter(|exercise| exercise.done)
+ .count(),
+ Filter::Pending => self
+ .app_state
+ .exercises()
+ .iter()
+ .filter(|exercise| !exercise.done)
+ .count(),
+ Filter::None => self.app_state.exercises().len(),
};
- frame.render_widget(
- message,
- Rect {
- x: 0,
- y: table_height + 2,
- width: area.width,
- height: 1 + narrow_u16,
- },
+
+ if self.n_rows_with_filter == 0 {
+ self.selected_row = None;
+ return;
+ }
+
+ self.set_selected(
+ self.selected_row
+ .map_or(0, |selected| selected.min(self.n_rows_with_filter - 1)),
);
+ }
- Ok(())
+ #[inline]
+ pub fn filter(&self) -> Filter {
+ self.filter
}
- pub fn with_reset_selected(mut self) -> Result<Self> {
- let Some(selected) = self.table_state.selected() else {
- return Ok(self);
- };
+ pub fn set_filter(&mut self, filter: Filter) {
+ self.filter = filter;
+ self.update_rows();
+ }
- let ind = self
- .app_state
- .exercises()
- .iter()
- .enumerate()
- .filter_map(|(ind, exercise)| match self.filter {
- Filter::Done => exercise.done.then_some(ind),
- Filter::Pending => (!exercise.done).then_some(ind),
- Filter::None => Some(ind),
- })
- .nth(selected)
- .context("Invalid selection index")?;
-
- let exercise_path = self.app_state.reset_exercise_by_ind(ind)?;
- write!(self.message, "The exercise {exercise_path} has been reset")?;
-
- Ok(self.with_updated_rows())
+ pub fn select_next(&mut self) {
+ if let Some(selected) = self.selected_row {
+ self.set_selected((selected + 1).min(self.n_rows_with_filter - 1));
+ }
}
- pub fn selected_to_current_exercise(&mut self) -> Result<()> {
- let Some(selected) = self.table_state.selected() else {
+ pub fn select_previous(&mut self) {
+ if let Some(selected) = self.selected_row {
+ self.set_selected(selected.saturating_sub(1));
+ }
+ }
+
+ pub fn select_first(&mut self) {
+ if self.n_rows_with_filter > 0 {
+ self.set_selected(0);
+ }
+ }
+
+ pub fn select_last(&mut self) {
+ if self.n_rows_with_filter > 0 {
+ self.set_selected(self.n_rows_with_filter - 1);
+ }
+ }
+
+ fn selected_to_exercise_ind(&self, selected: usize) -> Result<usize> {
+ match self.filter {
+ Filter::Done => self
+ .app_state
+ .exercises()
+ .iter()
+ .enumerate()
+ .filter(|(_, exercise)| exercise.done)
+ .nth(selected)
+ .context("Invalid selection index")
+ .map(|(ind, _)| ind),
+ Filter::Pending => self
+ .app_state
+ .exercises()
+ .iter()
+ .enumerate()
+ .filter(|(_, exercise)| !exercise.done)
+ .nth(selected)
+ .context("Invalid selection index")
+ .map(|(ind, _)| ind),
+ Filter::None => Ok(selected),
+ }
+ }
+
+ pub fn reset_selected(&mut self) -> Result<()> {
+ let Some(selected) = self.selected_row else {
+ self.message.push_str("Nothing selected to reset!");
return Ok(());
};
- let ind = self
- .app_state
- .exercises()
- .iter()
- .enumerate()
- .filter_map(|(ind, exercise)| match self.filter {
- Filter::Done => exercise.done.then_some(ind),
- Filter::Pending => (!exercise.done).then_some(ind),
- Filter::None => Some(ind),
- })
- .nth(selected)
- .context("Invalid selection index")?;
-
- self.app_state.set_current_exercise_ind(ind)
+ let exercise_ind = self.selected_to_exercise_ind(selected)?;
+ let exercise_name = self.app_state.reset_exercise_by_ind(exercise_ind)?;
+ self.update_rows();
+ write!(
+ self.message,
+ "The exercise `{exercise_name}` has been reset",
+ )?;
+
+ Ok(())
+ }
+
+ // Return `true` if there was something to select.
+ pub fn selected_to_current_exercise(&mut self) -> Result<bool> {
+ let Some(selected) = self.selected_row else {
+ self.message.push_str("Nothing selected to continue at!");
+ return Ok(false);
+ };
+
+ let exercise_ind = self.selected_to_exercise_ind(selected)?;
+ self.app_state.set_current_exercise_ind(exercise_ind)?;
+
+ Ok(true)
}
}