diff options
Diffstat (limited to 'src/watch')
| -rw-r--r-- | src/watch/notify_event.rs | 52 | ||||
| -rw-r--r-- | src/watch/state.rs | 174 | ||||
| -rw-r--r-- | src/watch/terminal_event.rs | 86 |
3 files changed, 312 insertions, 0 deletions
diff --git a/src/watch/notify_event.rs b/src/watch/notify_event.rs new file mode 100644 index 0000000..7471640 --- /dev/null +++ b/src/watch/notify_event.rs @@ -0,0 +1,52 @@ +use notify_debouncer_mini::{DebounceEventResult, DebouncedEventKind}; +use std::sync::mpsc::Sender; + +use super::WatchEvent; + +pub struct NotifyEventHandler { + pub tx: Sender<WatchEvent>, + /// Used to report which exercise was modified. + pub exercise_names: &'static [&'static [u8]], +} + +impl notify_debouncer_mini::DebounceEventHandler for NotifyEventHandler { + fn handle_event(&mut self, input_event: DebounceEventResult) { + let output_event = match input_event { + Ok(input_event) => { + let Some(exercise_ind) = input_event + .iter() + .filter_map(|input_event| { + if input_event.kind != DebouncedEventKind::Any { + return None; + } + + let file_name = input_event.path.file_name()?.to_str()?.as_bytes(); + + if file_name.len() < 4 { + return None; + } + let (file_name_without_ext, ext) = file_name.split_at(file_name.len() - 3); + + if ext != b".rs" { + return None; + } + + self.exercise_names + .iter() + .position(|exercise_name| *exercise_name == file_name_without_ext) + }) + .min() + else { + return; + }; + + WatchEvent::FileChange { exercise_ind } + } + Err(e) => WatchEvent::NotifyErr(e), + }; + + // An error occurs when the receiver is dropped. + // After dropping the receiver, the debouncer guard should also be dropped. + let _ = self.tx.send(output_event); + } +} diff --git a/src/watch/state.rs b/src/watch/state.rs new file mode 100644 index 0000000..78af30a --- /dev/null +++ b/src/watch/state.rs @@ -0,0 +1,174 @@ +use anyhow::Result; +use crossterm::{ + style::{style, Stylize}, + terminal, +}; +use std::io::{self, StdoutLock, Write}; + +use crate::{ + app_state::{AppState, ExercisesProgress}, + clear_terminal, + exercise::{RunnableExercise, OUTPUT_CAPACITY}, + progress_bar::progress_bar, + terminal_link::TerminalFileLink, +}; + +#[derive(PartialEq, Eq)] +enum DoneStatus { + DoneWithSolution(String), + DoneWithoutSolution, + Pending, +} + +pub struct WatchState<'a> { + writer: StdoutLock<'a>, + app_state: &'a mut AppState, + output: Vec<u8>, + show_hint: bool, + done_status: DoneStatus, + manual_run: bool, +} + +impl<'a> WatchState<'a> { + pub fn new(app_state: &'a mut AppState, manual_run: bool) -> Self { + let writer = io::stdout().lock(); + + Self { + writer, + app_state, + output: Vec::with_capacity(OUTPUT_CAPACITY), + show_hint: false, + done_status: DoneStatus::Pending, + manual_run, + } + } + + #[inline] + pub fn into_writer(self) -> StdoutLock<'a> { + self.writer + } + + pub fn run_current_exercise(&mut self) -> Result<()> { + self.show_hint = false; + + let success = self + .app_state + .current_exercise() + .run_exercise(&mut self.output, self.app_state.target_dir())?; + if success { + self.done_status = + if let Some(solution_path) = self.app_state.current_solution_path()? { + DoneStatus::DoneWithSolution(solution_path) + } else { + DoneStatus::DoneWithoutSolution + }; + } else { + self.app_state + .set_pending(self.app_state.current_exercise_ind())?; + + self.done_status = DoneStatus::Pending; + } + + self.render() + } + + pub fn handle_file_change(&mut self, exercise_ind: usize) -> Result<()> { + // Don't skip exercises on file changes to avoid confusion from missing exercises. + // Skipping exercises must be explicit in the interactive list. + // But going back to an earlier exercise on file change is fine. + if self.app_state.current_exercise_ind() < exercise_ind { + return Ok(()); + } + + self.app_state.set_current_exercise_ind(exercise_ind)?; + self.run_current_exercise() + } + + /// Move on to the next exercise if the current one is done. + pub fn next_exercise(&mut self) -> Result<ExercisesProgress> { + if self.done_status == DoneStatus::Pending { + return Ok(ExercisesProgress::CurrentPending); + } + + self.app_state.done_current_exercise(&mut self.writer) + } + + fn show_prompt(&mut self) -> io::Result<()> { + self.writer.write_all(b"\n")?; + + if self.manual_run { + write!(self.writer, "{}:run / ", 'r'.bold())?; + } + + if self.done_status != DoneStatus::Pending { + write!(self.writer, "{}:{} / ", 'n'.bold(), "next".underlined())?; + } + + if !self.show_hint { + write!(self.writer, "{}:hint / ", 'h'.bold())?; + } + + write!(self.writer, "{}:list / {}:quit ? ", 'l'.bold(), 'q'.bold())?; + + self.writer.flush() + } + + pub fn render(&mut self) -> Result<()> { + // Prevent having the first line shifted if clearing wasn't successful. + self.writer.write_all(b"\n")?; + + clear_terminal(&mut self.writer)?; + + self.writer.write_all(&self.output)?; + self.writer.write_all(b"\n")?; + + if self.show_hint { + writeln!( + self.writer, + "{}\n{}\n", + "Hint".bold().cyan().underlined(), + self.app_state.current_exercise().hint, + )?; + } + + if self.done_status != DoneStatus::Pending { + writeln!( + self.writer, + "{}\n", + "Exercise done ✓ +When you are done experimenting, enter `n` to move on to the next exercise 🦀" + .bold() + .green(), + )?; + } + + if let DoneStatus::DoneWithSolution(solution_path) = &self.done_status { + writeln!( + self.writer, + "A solution file can be found at {}\n", + style(TerminalFileLink(solution_path)).underlined().green(), + )?; + } + + let line_width = terminal::size()?.0; + let progress_bar = progress_bar( + self.app_state.n_done(), + self.app_state.exercises().len() as u16, + line_width, + )?; + writeln!( + self.writer, + "{progress_bar}Current exercise: {}", + self.app_state.current_exercise().terminal_link(), + )?; + + self.show_prompt()?; + + Ok(()) + } + + pub fn show_hint(&mut self) -> Result<()> { + self.show_hint = true; + self.render() + } +} diff --git a/src/watch/terminal_event.rs b/src/watch/terminal_event.rs new file mode 100644 index 0000000..f54af17 --- /dev/null +++ b/src/watch/terminal_event.rs @@ -0,0 +1,86 @@ +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use std::sync::mpsc::Sender; + +use super::WatchEvent; + +pub enum InputEvent { + Run, + Next, + Hint, + List, + Quit, + Unrecognized, +} + +pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) { + // Only send `Unrecognized` on ENTER if the last input wasn't valid. + let mut last_input_valid = false; + + let last_input_event = loop { + let terminal_event = match event::read() { + Ok(v) => v, + Err(e) => { + // If `send` returns an error, then the receiver is dropped and + // a shutdown has been already initialized. + let _ = tx.send(WatchEvent::TerminalEventErr(e)); + return; + } + }; + + match terminal_event { + Event::Key(key) => { + match key.kind { + KeyEventKind::Release | KeyEventKind::Repeat => continue, + KeyEventKind::Press => (), + } + + if key.modifiers != KeyModifiers::NONE { + last_input_valid = false; + continue; + } + + let input_event = match key.code { + KeyCode::Enter => { + if last_input_valid { + continue; + } + + InputEvent::Unrecognized + } + KeyCode::Char(c) => { + let input_event = match c { + 'n' => InputEvent::Next, + 'h' => InputEvent::Hint, + 'l' => break InputEvent::List, + 'q' => break InputEvent::Quit, + 'r' if manual_run => InputEvent::Run, + _ => { + last_input_valid = false; + continue; + } + }; + + last_input_valid = true; + input_event + } + _ => { + last_input_valid = false; + continue; + } + }; + + if tx.send(WatchEvent::Input(input_event)).is_err() { + return; + } + } + Event::Resize(_, _) => { + if tx.send(WatchEvent::TerminalResize).is_err() { + return; + } + } + Event::FocusGained | Event::FocusLost | Event::Mouse(_) | Event::Paste(_) => continue, + } + }; + + let _ = tx.send(WatchEvent::Input(last_input_event)); +} |
