diff --git a/src/list.rs b/src/list.rs new file mode 100644 index 0000000..f8713b0 --- /dev/null +++ b/src/list.rs @@ -0,0 +1,93 @@ +use std::{io, time::Duration}; + +use anyhow::Result; +use crossterm::{ + event::{self, KeyCode, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::Constraint, + style::{Modifier, Style, Stylize}, + text::Span, + widgets::{Block, Borders, HighlightSpacing, Row, Table, TableState}, + Terminal, +}; + +use crate::{exercise::Exercise, state::State}; + +// 40 FPS. +const UPDATE_INTERVAL: Duration = Duration::from_millis(25); + +pub fn list(state: &State, exercises: &[Exercise]) -> Result<()> { + let mut stdout = io::stdout().lock(); + + stdout.execute(EnterAlternateScreen)?; + enable_raw_mode()?; + + let mut terminal = Terminal::new(CrosstermBackend::new(&mut stdout))?; + terminal.clear()?; + + let header = Row::new(["State", "Name", "Path"]); + + let max_name_len = exercises + .iter() + .map(|exercise| exercise.name.len()) + .max() + .unwrap_or(4) as u16; + + let widths = [ + Constraint::Length(7), + Constraint::Length(max_name_len), + Constraint::Fill(1), + ]; + + let rows = exercises + .iter() + .zip(&state.progress) + .map(|(exercise, done)| { + let state = if *done { + "DONE".green() + } else { + "PENDING".yellow() + }; + Row::new([ + state, + Span::raw(&exercise.name), + Span::raw(exercise.path.to_string_lossy()), + ]) + }) + .collect::>(); + + let table = Table::new(rows, widths) + .header(header) + .column_spacing(2) + .highlight_spacing(HighlightSpacing::Always) + .highlight_style(Style::new().add_modifier(Modifier::REVERSED)) + .highlight_symbol("🦀"); + + let mut table_state = TableState::default().with_selected(Some(0)); + + loop { + terminal.draw(|frame| { + let area = frame.size(); + + frame.render_stateful_widget(&table, area, &mut table_state); + })?; + + if event::poll(UPDATE_INTERVAL)? { + if let event::Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { + break; + } + } + } + } + + drop(terminal); + stdout.execute(LeaveAlternateScreen)?; + disable_raw_mode()?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index e8218ef..34d1784 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ use crate::verify::verify; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use state::State; -use std::io::Write; use std::path::Path; use std::process::exit; use verify::VerifyState; @@ -15,6 +14,7 @@ mod consts; mod embedded; mod exercise; mod init; +mod list; mod run; mod state; mod verify; @@ -52,24 +52,7 @@ enum Subcommands { name: String, }, /// List the exercises available in Rustlings - List { - /// Show only the paths of the exercises - #[arg(short, long)] - paths: bool, - /// Show only the names of the exercises - #[arg(short, long)] - names: bool, - /// Provide a string to match exercise names. - /// Comma separated patterns are accepted - #[arg(short, long)] - filter: Option, - /// Display only exercises not yet solved - #[arg(short, long)] - unsolved: bool, - /// Display only exercises that have been solved - #[arg(short, long)] - solved: bool, - }, + List, } fn main() -> Result<()> { @@ -110,79 +93,8 @@ If you are just starting with Rustlings, run the command `rustlings init` to ini } // `Init` is handled above. Some(Subcommands::Init) => (), - Some(Subcommands::List { - paths, - names, - filter, - unsolved, - solved, - }) => { - if !paths && !names { - println!("{:<17}\t{:<46}\t{:<7}", "Name", "Path", "Status"); - } - let mut exercises_done: u16 = 0; - let lowercase_filter = filter - .as_ref() - .map(|s| s.to_lowercase()) - .unwrap_or_default(); - let filters = lowercase_filter - .split(',') - .filter_map(|f| { - let f = f.trim(); - if f.is_empty() { - None - } else { - Some(f) - } - }) - .collect::>(); - - for exercise in &exercises { - let fname = exercise.path.to_string_lossy(); - let filter_cond = filters - .iter() - .any(|f| exercise.name.contains(f) || fname.contains(f)); - let looks_done = exercise.looks_done()?; - let status = if looks_done { - exercises_done += 1; - "Done" - } else { - "Pending" - }; - let solve_cond = - (looks_done && solved) || (!looks_done && unsolved) || (!solved && !unsolved); - if solve_cond && (filter_cond || filter.is_none()) { - let line = if paths { - format!("{fname}\n") - } else if names { - format!("{}\n", exercise.name) - } else { - format!("{:<17}\t{fname:<46}\t{status:<7}\n", exercise.name) - }; - // Somehow using println! leads to the binary panicking - // when its output is piped. - // So, we're handling a Broken Pipe error and exiting with 0 anyway - let stdout = std::io::stdout(); - { - let mut handle = stdout.lock(); - handle.write_all(line.as_bytes()).unwrap_or_else(|e| { - match e.kind() { - std::io::ErrorKind::BrokenPipe => exit(0), - _ => exit(1), - }; - }); - } - } - } - - let percentage_progress = exercises_done as f32 / exercises.len() as f32 * 100.0; - println!( - "Progress: You completed {} / {} exercises ({:.1} %).", - exercises_done, - exercises.len(), - percentage_progress - ); - exit(0); + Some(Subcommands::List) => { + list::list(&state, &exercises)?; } Some(Subcommands::Run { name }) => { let exercise = find_exercise(&name, &exercises)?;