diff --git a/src/flashcards.rs b/src/flashcards.rs index 721de98..6c13d6a 100644 --- a/src/flashcards.rs +++ b/src/flashcards.rs @@ -3,7 +3,8 @@ use std::{ fs, ops::{Index, IndexMut, Not}, path::Path, - str::FromStr, + rc::Rc, + str::{Chars, FromStr}, }; use crossterm::style::Color; @@ -41,6 +42,13 @@ impl Set { } } } + + pub fn recall_settings(&self, side: Side) -> &RecallSettings { + match side { + Side::Term => &self.recall_t, + Side::Definition => &self.recall_d, + } + } } impl FromStr for Set { @@ -97,9 +105,13 @@ impl FromStr for Set { true } else { match line.split_once(':') { - Some(("T", term)) => card[Side::Term].push(trim(term).to_owned()), + Some(("T", term)) => card[Side::Term].push_display(trim(term)), Some(("D", definition)) => { - card[Side::Definition].push(trim(definition).to_owned()) + card[Side::Definition].push_display(trim(definition)) + } + Some(("t", term)) => card[Side::Term].push_accepted(trim(term)), + Some(("d", definition)) => { + card[Side::Definition].push_accepted(trim(definition)) } Some((tag, _)) => errors.push(ParseFlashcardItemError::UnknownTag { tag: tag.to_owned(), @@ -281,10 +293,11 @@ macro_rules! load_set { }; } +// NOT `Copy` because non-Copy fields may be added in the future #[derive(Debug, Default, Clone)] pub struct RecallSettings { - matching: bool, - text: bool, + pub matching: bool, + pub text: bool, } #[derive(Debug, Clone)] @@ -330,32 +343,92 @@ impl IndexMut for Flashcard { } #[derive(Debug, Clone)] -pub struct FlashcardText(SmallVec<[String; 1]>); +pub struct FlashcardText { + vals: SmallVec<[Rc; 1]>, + num_display: u8, +} impl FlashcardText { const fn empty() -> Self { - FlashcardText(SmallVec::new_const()) + FlashcardText { + vals: SmallVec::new_const(), + num_display: 0, + } } /// Returns true if this is valid /// /// A flashcard text is valid if it has at least 1 value fn is_valid(&self) -> bool { - !self.0.is_empty() + self.num_display > 0 + } + + pub fn display_options(&self) -> &[Rc] { + &self.vals[..self.num_display as usize] + } + + pub fn push_display(&mut self, val: impl Into>) { + self.vals.push(val.into()); + self.num_display = self + .num_display + .checked_add(1) + .expect("Flascards cannot have more than 255 values!"); } - pub fn push(&mut self, val: String) { - self.0.push(val); + pub fn push_accepted(&mut self, val: impl Into>) { + self.vals.push(val.into()); } - pub fn display(&self) -> &str { - self.0.choose(&mut rand::thread_rng()).unwrap() + pub fn display(&self) -> &Rc { + self.display_options() + .choose(&mut rand::thread_rng()) + .unwrap() + } + + pub fn contains(&self, val: &str, settings: &RecallSettings) -> bool { + fn eq_with_settings(pat: &str, val: &str, settings: &RecallSettings) -> bool { + struct Iter<'a> { + chars_iter: Chars<'a>, + #[allow(dead_code)] + settings: &'a RecallSettings, + } + impl<'a> Iterator for Iter<'a> { + type Item = char; + + fn next(&mut self) -> Option { + self.chars_iter + .by_ref() + .find(|c| !c.is_ascii_whitespace()) + .map(|c| c.to_ascii_lowercase()) + } + } + Iter { + chars_iter: pat.chars(), + settings, + } + .eq(Iter { + chars_iter: val.chars(), + settings, + }) + } + self.vals + .iter() + .any(|pat| eq_with_settings(pat, val, settings)) } } impl FlashcardText { - pub fn new(text: String) -> Self { - Self(smallvec![text]) + pub fn new(text: impl Into>) -> Self { + Self { + vals: smallvec![text.into()], + num_display: 0, + } + } +} + +impl From> for FlashcardText { + fn from(s: Rc) -> Self { + Self::new(s) } } @@ -367,20 +440,14 @@ impl From for FlashcardText { impl From<&str> for FlashcardText { fn from(s: &str) -> Self { - Self::new(s.to_owned()) - } -} - -impl From<&[&str]> for FlashcardText { - fn from(list: &[&str]) -> Self { - Self(list.iter().map(|&s| s.to_owned()).collect()) + Self::new(s) } } #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Side { - Term, - Definition, + Term = 0, + Definition = 1, } impl Side { diff --git a/src/input.rs b/src/input.rs index 416c0dc..1c2f1ca 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,100 +1,2 @@ -#[macro_export] -macro_rules! up { - () => { - crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Up, - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('w'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('W'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('k'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('K'), - .. - }) - }; -} - -#[macro_export] -macro_rules! down { - () => { - crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Down, - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('s'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('S'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('j'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('J'), - .. - }) - }; -} - -#[macro_export] -macro_rules! left { - () => { - crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Left, - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('a'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('A'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('h'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('H'), - .. - }) - }; -} - -#[macro_export] -macro_rules! right { - () => { - crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Right, - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('d'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('D'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('l'), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('L'), - .. - }) - }; -} - -#[macro_export] -macro_rules! click { - () => { - crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char(' '), - .. - }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Enter, - .. - }) - }; -} +pub mod macros; +pub mod text; diff --git a/src/input/macros.rs b/src/input/macros.rs new file mode 100644 index 0000000..416c0dc --- /dev/null +++ b/src/input/macros.rs @@ -0,0 +1,100 @@ +#[macro_export] +macro_rules! up { + () => { + crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Up, + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('w'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('W'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('k'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('K'), + .. + }) + }; +} + +#[macro_export] +macro_rules! down { + () => { + crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Down, + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('s'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('S'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('j'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('J'), + .. + }) + }; +} + +#[macro_export] +macro_rules! left { + () => { + crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Left, + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('a'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('A'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('h'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('H'), + .. + }) + }; +} + +#[macro_export] +macro_rules! right { + () => { + crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Right, + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('d'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('D'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('l'), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char('L'), + .. + }) + }; +} + +#[macro_export] +macro_rules! click { + () => { + crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Char(' '), + .. + }) | crossterm::event::Event::Key(crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::Enter, + .. + }) + }; +} diff --git a/src/input/text.rs b/src/input/text.rs new file mode 100644 index 0000000..8f59291 --- /dev/null +++ b/src/input/text.rs @@ -0,0 +1,447 @@ +use std::{borrow::Cow, io, rc::Rc}; + +use crossterm::{ + cursor, + event::KeyCode, + queue, + style::{self, Color}, +}; + +use crate::{ + output::{self, text_box::OutlineType, word_wrap::WordWrap, Repeat, TextAlign}, + vec2::{Rect, Vec2}, +}; + +#[derive(Debug)] +pub struct TextInput = Rc> { + dims: Rect, + outline_type: Option, + outline_color: Color, + text_buffers: (String, String), + cursor_pos: usize, + correct_answer: Option, +} + +impl> TextInput { + pub fn new(dims: Rect) -> Self { + Self { + dims: Self::make_valid_dims(dims), + outline_type: None, + outline_color: Color::Reset, + text_buffers: Default::default(), + cursor_pos: 0, + correct_answer: None, + } + } + + fn make_valid_dims(mut dims: Rect) -> Rect { + dims.size = dims.size.join(Vec2::new(5, 3), |a, b| a.max(b)); + dims + } + + /// Reads a single key code of input + /// Does not move the cursor, [`go_to_cursor`] should be called after this + /// If the user pressed enter, returns the text in this + pub fn read_input(&mut self, code: KeyCode) -> Option<&str> { + fn redraw_text, O>( + this: &mut TextInput, + mut f: impl FnMut(&mut String, usize) -> O, + ) -> O { + let ret = f(&mut this.text_buffers.0, this.cursor_pos); + this.overwrite_text(); + f(&mut this.text_buffers.1, this.cursor_pos); + ret + } + + match code { + KeyCode::Enter => Some(&self.text_buffers.0), + KeyCode::Backspace => { + if self.cursor_pos > 0 { + self.cursor_pos = + output::floor_char_boundary(&self.text_buffers.0, self.cursor_pos - 1); + redraw_text(self, String::remove); + } + None + } + KeyCode::Delete => { + if self.cursor_pos < self.text_buffers.0.len() { + redraw_text(self, String::remove); + } + None + } + KeyCode::Left => { + if self.cursor_pos > 0 { + self.cursor_pos = + output::floor_char_boundary(&self.text_buffers.0, self.cursor_pos - 1); + } + None + } + KeyCode::Right => { + if let Some(cursor_pos) = + output::ceil_char_boundary(&self.text_buffers.0, self.cursor_pos + 1) + { + self.cursor_pos = cursor_pos; + } + None + } + KeyCode::Char(c) => { + redraw_text(self, |s, pos| s.insert(pos, c)); + self.cursor_pos += c.len_utf8(); + None + } + _ => None, + } + } + + pub fn get_text(&self) -> &str { + &self.text_buffers.0 + } + + fn overwrite_text(&self) { + if let Some(correct_answer) = &self.correct_answer { + let dims = self.inner_size(); + + let mut buffers = ( + WordWrap::new(&self.text_buffers.0, dims.size.x as usize), + WordWrap::new(&self.text_buffers.1, dims.size.x as usize), + ); + let mut correct_answer = WordWrap::new(correct_answer.as_ref(), dims.size.x as usize); + for y in dims.pos.y..dims.pos.y + dims.size.y { + let buffers = (buffers.0.next(), buffers.1.next()); + let correct_answer = correct_answer.next(); + + if buffers.0.is_none() && buffers.1.is_none() && correct_answer.is_none() { + break; + } + + if buffers.0 != buffers.1 { + if buffers.0.is_some() || correct_answer.is_some() { + draw_diff_line(dims.pos.x, y, buffers.0.as_ref(), correct_answer.as_ref()); + } else { + queue!(io::stdout(), cursor::MoveTo(dims.pos.x, y)).unwrap(); + } + if let Some(buffer_1) = buffers.1 { + if let Some(len) = buffer_1.trim_end().chars().count().checked_sub( + buffers + .0 + .unwrap_or(Cow::Borrowed("")) + .trim_end() + .chars() + .count(), + ) { + queue!(io::stdout(), style::Print(Repeat(' ', len as u16))).unwrap(); + } + } + } + } + } else { + output::overwrite_text( + self.inner_size(), + Color::White, + &self.text_buffers.1, + TextAlign::TOP_LEFT, + &self.text_buffers.0, + TextAlign::TOP_LEFT, + false, + ); + } + } + + fn redraw_text(&mut self) { + if let Some(correct_answer) = &self.correct_answer { + let dims = self.inner_size(); + + let mut first = WordWrap::new(&self.text_buffers.0, dims.size.x as usize); + let mut second = WordWrap::new(correct_answer.as_ref(), dims.size.x as usize); + for y in dims.pos.y..dims.pos.y + dims.size.y { + let first = first.next(); + let second = second.next(); + + if first.is_none() && second.is_none() { + break; + } + + draw_diff_line(dims.pos.x, y, first.as_ref(), second.as_ref()) + } + } else { + output::draw_text( + self.inner_size(), + Color::White, + &self.text_buffers.0, + TextAlign::TOP_LEFT, + ); + } + } + + fn erase_text(&mut self) { + if let Some(correct_answer) = &self.correct_answer { + let dims = self.inner_size(); + + fn find_len(t: Option>) -> u16 { + t.map(|t| t.chars().count()).unwrap_or(0) as u16 + } + + let mut first = WordWrap::new(&self.text_buffers.0, dims.size.x as usize); + let mut second = WordWrap::new(correct_answer.as_ref(), dims.size.x as usize); + for y in dims.pos.y..dims.pos.y + dims.size.y { + let first = first.next(); + let second = second.next(); + + if first.is_none() && second.is_none() { + break; + } + + queue!( + io::stdout(), + cursor::MoveTo(dims.pos.x, y), + style::Print(Repeat(' ', find_len(first).max(find_len(second)))) + ) + .unwrap(); + } + } else { + output::overwrite_text( + self.inner_size(), + Color::White, + &self.text_buffers.1, + TextAlign::TOP_LEFT, + "", + TextAlign::TOP_LEFT, + true, + ) + } + } + + pub fn update(&mut self, f: impl FnOnce(&mut TextInputUpdater)) { + let mut updater = TextInputUpdater { + inner: self, + redraw_outline: false, + new_correct_answer: None, + }; + f(&mut updater); + let TextInputUpdater { + inner: _, + redraw_outline, + new_correct_answer, + } = updater; + + if redraw_outline { + output::draw_outline( + self.dims, + self.outline_color, + self.outline_type.unwrap_or(OutlineType::ERASE), + ); + } + + if let Some(new_correct_answer) = new_correct_answer { + self.erase_text(); + self.correct_answer = new_correct_answer; + self.text_buffers.1.clone_from(&self.text_buffers.0); + self.redraw_text(); + } else if self.text_buffers.0 != self.text_buffers.1 { + self.overwrite_text(); + self.text_buffers.1.clone_from(&self.text_buffers.0); + } + } + + fn inner_size(&self) -> Rect { + self.dims.shrink_centered(Vec2::splat(1)) + } + + // Moves the terminal cursor to the position of the cursor in this + pub fn go_to_cursor(&self) { + let mut last_len = self.text_buffers.0.len(); + let mut wrap = WordWrap::new(&self.text_buffers.0, (self.dims.size.x - 2) as usize); + let mut cursor_pos = self.cursor_pos; + for y in 0.. { + if let Some(line) = wrap.next() { + let len = wrap.remaining_text_len(); + let diff = last_len - len; + if cursor_pos < diff || len == 0 { + if cursor_pos < line.len() { + queue!( + io::stdout(), + cursor::MoveTo( + self.dims.pos.x + 1 + (line[..cursor_pos].chars().count()) as u16, + self.dims.pos.y + 1 + y, + ), + ) + .unwrap(); + } else { + queue!( + io::stdout(), + cursor::MoveTo( + self.dims.pos.x + + 1 + + line.chars().count() as u16 + + (cursor_pos - line.len()) as u16, + self.dims.pos.y + 1 + y, + ), + ) + .unwrap(); + } + break; + } + last_len = len; + cursor_pos -= diff; + } else { + queue!( + io::stdout(), + cursor::MoveTo(self.dims.pos.x + 1, self.dims.pos.y + 1 + y,), + ) + .unwrap(); + break; + } + } + } + + pub fn hide(&mut self) { + self.update(|updater| { + updater.clear_outline().clear_text().clear_correct_answer(); + }); + } + + pub fn correct_answer_is(&mut self, answer: S) { + self.correct_answer = Some(answer); + self.redraw_text(); + } + + /// Moves and redraws this without erasing it's past position first + pub fn force_move_resize(&mut self, new_dims: Rect) { + self.dims = Self::make_valid_dims(new_dims); + if let Some(outline_type) = self.outline_type { + output::draw_outline(self.dims, self.outline_color, outline_type); + } + self.redraw_text(); + } +} + +fn draw_diff_line(x: u16, y: u16, first_line: Option<&Cow>, second_line: Option<&Cow>) { + match (first_line, second_line) { + (None, None) => unreachable!(), + (None, Some(line)) => { + queue!( + io::stdout(), + cursor::MoveTo(x, y), + style::SetForegroundColor(Diff::SecondOnly.color()), + style::Print(line), + ) + .unwrap(); + } + (Some(line), None) => { + queue!( + io::stdout(), + cursor::MoveTo(x, y), + style::SetForegroundColor(Diff::FirstOnly.color()), + style::Print(line), + ) + .unwrap(); + } + (Some(first_line), Some(second_line)) => { + queue!(io::stdout(), cursor::MoveTo(x, y)).unwrap(); + + let mut last_diff: Option = None; + let mut print_next_diff = |diff, ch: char| { + if last_diff == Some(diff) { + queue!(io::stdout(), style::Print(ch)).unwrap(); + } else { + last_diff = Some(diff); + queue!( + io::stdout(), + style::SetForegroundColor(diff.color()), + style::Print(ch), + ) + .unwrap(); + } + }; + + let mut first = first_line.trim_end().chars(); + let mut second = second_line.trim_end().chars(); + + loop { + let first = first.next(); + let second = second.next(); + + if first.is_none() && second.is_none() { + break; + } + + match ( + first.filter(|c| !c.is_whitespace()), + second.filter(|c| !c.is_whitespace()), + ) { + (None, None) => { + queue!(io::stdout(), style::Print(' ')).unwrap(); + } + (None, Some(s)) => print_next_diff(Diff::SecondOnly, s), + (Some(f), None) => print_next_diff(Diff::FirstOnly, f), + (Some(f), Some(s)) if f == s => print_next_diff(Diff::Same, f), + (Some(f), Some(_)) => print_next_diff(Diff::Different, f), + } + } + } + } +} + +#[derive(Debug)] +pub struct TextInputUpdater<'a, S: AsRef> { + inner: &'a mut TextInput, + redraw_outline: bool, + new_correct_answer: Option>, +} + +impl<'a, S: AsRef> TextInputUpdater<'a, S> { + pub fn set_outline(&mut self, outline: OutlineType) -> &mut Self { + self.redraw_outline |= !set_and_compare(&mut self.inner.outline_type, Some(outline)); + self + } + + pub fn clear_outline(&mut self) -> &mut Self { + self.redraw_outline |= !set_and_compare(&mut self.inner.outline_type, None); + self + } + + pub fn clear_text(&mut self) -> &mut Self { + self.inner.text_buffers.0.clear(); + self.inner.cursor_pos = 0; + self + } + + pub fn clear_correct_answer(&mut self) -> &mut Self { + self.new_correct_answer = Some(None); + self + } +} + +/// Sets `dst` to `new`, and returns true if they compare equal +fn set_and_compare(dst: &mut T, new: T) -> bool { + let flag = *dst == new; + *dst = new; + flag +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Diff { + Same, + Different, + FirstOnly, + SecondOnly, +} + +impl Diff { + pub const fn color(self) -> Color { + match self { + Diff::Same => Color::White, + Diff::Different => Color::DarkRed, + Diff::FirstOnly => Color::Rgb { + r: 255, + g: 105, + b: 180, + }, + Diff::SecondOnly => Color::Rgb { + r: 128, + g: 128, + b: 128, + }, + } + } +} diff --git a/src/main.rs b/src/main.rs index 4b7abf9..007d24a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,11 +19,13 @@ struct EasyFlashCards { enum Subcommand { Debug(debug::Entry), Flashcards(study::flashcards::Entry), + Learn(study::learn::Entry), } fn main() { match argh::from_env::().subcommand { Subcommand::Debug(cmd) => cmd.run(), Subcommand::Flashcards(cmd) => cmd.run(), + Subcommand::Learn(cmd) => cmd.run(), } } diff --git a/src/output.rs b/src/output.rs index 63cd9ae..1de4082 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,19 +1,42 @@ +use core::fmt; use std::{borrow::Cow, fmt::Display, io}; use crossterm::{ cursor, execute, queue, - style::{self, Attribute, Attributes, Color, Stylize}, + style::{self, Color, Stylize}, terminal, }; -use crate::{output::word_wrap::WordWrap, vec2::Vec2}; +use crate::{ + output::word_wrap::WordWrap, + vec2::{Rect, Vec2}, +}; + +use self::text_box::OutlineType; +pub mod text_box; pub mod word_wrap; pub fn write_fatal_error(text: &str) { println!("{}", text.dark_red()); } +pub fn floor_char_boundary(s: &str, mut pos: usize) -> usize { + while !s.is_char_boundary(pos) { + pos -= 1; + } + pos +} + +pub fn ceil_char_boundary(s: &str, mut pos: usize) -> Option { + (pos <= s.len()).then(|| { + while !s.is_char_boundary(pos) { + pos += 1; + } + pos + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Repeat(pub char, pub u16); @@ -31,6 +54,7 @@ pub struct TerminalSettings { alternate_screen: bool, cursor_hidden: bool, raw_mode: bool, + exiting_properly: bool, } #[allow(dead_code)] @@ -74,10 +98,17 @@ impl TerminalSettings { self.raw_mode = false; self } + + pub fn clear(mut self) { + self.exiting_properly = true; + } } impl Drop for TerminalSettings { fn drop(&mut self) { + if !self.exiting_properly { + std::thread::sleep(std::time::Duration::from_secs(10)); + } if self.alternate_screen { let _ = execute!(io::stdout(), terminal::LeaveAlternateScreen); } @@ -87,566 +118,247 @@ impl Drop for TerminalSettings { if self.raw_mode { let _ = terminal::disable_raw_mode(); } + let _ = execute!(io::stdout(), style::SetForegroundColor(Color::Reset)); } } -#[derive(Debug, Clone)] -pub struct TextBox { - pub pos: Vec2, - pub size: Vec2, - pub outline: Option, - pub text_align_h: TextAlignH, - pub text_align_v: TextAlignV, - pub outline_color: Color, - pub content_color: Color, - pub attributes: Attributes, -} - +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] -impl TextBox { - /// Draws a text box on screen. Does not flush stdout - /// - /// # Panics - /// - /// Panics if size is not at least 5x3 (outlined) or at least 3x1 (no outline) - pub fn draw_outline_and_text(&self, text: &str) -> &Self { - // TODO: improve rendering? - self.draw_outline().draw_text(text) - } - - /// Draws just the outline of this, or does nothing if `self.outline` is `None` - /// - /// # Panics - /// - /// Panics if size is not at least 2x2 and this is outlined - pub fn draw_outline(&self) -> &Self { - if let Some(outline) = self.outline { - assert!(self.size.x >= 2 && self.size.y >= 2); - - queue!( - io::stdout(), - self.pos.move_to(), - style::SetForegroundColor(self.outline_color), - style::SetAttributes(self.attributes), - style::Print(outline.tl), - style::Print(Repeat(outline.h, self.size.x - 2)), - style::Print(outline.tr) - ) - .unwrap(); - for _ in 0..(self.size.y - 2) { - queue!( - io::stdout(), - cursor::MoveDown(1), - cursor::MoveToColumn(self.pos.x), - style::Print(outline.v), - cursor::MoveRight(self.size.x - 2), - style::Print(outline.v), - ) - .unwrap(); - } - queue!( - io::stdout(), - cursor::MoveDown(1), - cursor::MoveToColumn(self.pos.x), - style::Print(outline.bl), - style::Print(Repeat(outline.h, self.size.x - 2)), - style::Print(outline.br) - ) - .unwrap(); - } - self - } +pub enum TextAlignH { + Left, + Center, + Right, +} - /// Draws just the text of this - /// - /// # Panics - /// - /// Panics if size is not at least 5x3 (outlined) or at least 3x1 (no outline) - pub fn draw_text(&self, text: &str) -> &Self { - let lines_iter = self.get_lines_iter(text); - queue!(io::stdout(), style::SetForegroundColor(self.content_color)).unwrap(); - - match self.text_align_h { - TextAlignH::Left => self.draw_text_left_align(lines_iter), - TextAlignH::Center => self.draw_text_center_align(lines_iter), - TextAlignH::Right => self.draw_text_right_align(lines_iter), +impl TextAlignH { + fn padding_for(self, s: &str, width: impl Into) -> u16 { + match self { + TextAlignH::Left => 0, + TextAlignH::Center => ((width.into() - s.chars().count()) / 2) as u16, + TextAlignH::Right => (width.into() - s.chars().count()) as u16, } - self } +} - fn get_lines_iter<'a>(&self, text: &'a str) -> impl Iterator> { - let inner_size = self.inner_size(); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum TextAlignV { + Top, + Center, + Bottom, +} - enum LinesIter<'a> { - Top(WordWrap<'a>), - Other(std::vec::IntoIter>, usize), - } - impl<'a> Iterator for LinesIter<'a> { - type Item = Cow<'a, str>; - - fn next(&mut self) -> Option { - match self { - LinesIter::Top(iter) => iter.next(), - LinesIter::Other(iter, offset) => { - if *offset > 0 { - *offset -= 1; - Some(Cow::Borrowed("")) - } else { - iter.next() - } - } - } - } - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextAlign { + h: TextAlignH, + v: TextAlignV, +} - match self.text_align_v { - TextAlignV::Top => LinesIter::Top(WordWrap::new(text, inner_size.x as usize)), - _ => { - let lines = { - let mut lines = WordWrap::new(text, inner_size.x as usize); - let mut vec = Vec::from_iter(lines.by_ref().take(inner_size.y as usize)); - if lines.next().is_some() { - if let Some(line) = vec.last_mut() { - let line = line.to_mut(); - let mut len = line.chars().count(); - while len > (inner_size.x - 3) as usize { - line.pop(); - len -= 1; - } - line.push_str("..."); - } - } - vec - }; - let len = lines.len(); - LinesIter::Other( - lines.into_iter(), - match self.text_align_v { - TextAlignV::Top => unreachable!(), - TextAlignV::Center => (inner_size.y as usize).saturating_sub(len) / 2, - TextAlignV::Bottom => (inner_size.y as usize).saturating_sub(len), - }, - ) - } - } - } +impl TextAlign { + pub const CENTER: Self = Self { + h: TextAlignH::Center, + v: TextAlignV::Center, + }; - fn draw_text_left_align<'a>(&self, lines: impl Iterator>) { - let inner_size = self.inner_size(); - let corner_pos = if self.outline.is_some() { - self.pos + Vec2::splat(1) - } else { - self.pos - }; + pub const TOP_LEFT: Self = Self { + h: TextAlignH::Left, + v: TextAlignV::Top, + }; +} +pub fn draw_outline(dims: Rect, color: Color, typ: OutlineType) { + queue!( + io::stdout(), + dims.pos.move_to(), + style::SetForegroundColor(color), + style::Print(typ.tl), + style::Print(Repeat(typ.h, dims.size.x - 2)), + style::Print(typ.tr) + ) + .unwrap(); + for _ in 0..(dims.size.y - 2) { queue!( io::stdout(), - corner_pos.move_to(), - style::SetForegroundColor(self.content_color), - style::SetAttributes(self.attributes) + cursor::MoveDown(1), + cursor::MoveToColumn(dims.pos.x), + style::Print(typ.v), + cursor::MoveRight(dims.size.x - 2), + style::Print(typ.v), ) .unwrap(); - for line in lines.take(inner_size.y as usize) { - queue!( - io::stdout(), - style::Print(line), - cursor::MoveDown(1), - cursor::MoveToColumn(corner_pos.x) - ) - .unwrap(); - } } + queue!( + io::stdout(), + cursor::MoveDown(1), + cursor::MoveToColumn(dims.pos.x), + style::Print(typ.bl), + style::Print(Repeat(typ.h, dims.size.x - 2)), + style::Print(typ.br) + ) + .unwrap(); +} - fn draw_text_center_align<'a>(&self, lines: impl Iterator>) { - let inner_size = self.inner_size(); - let corner_pos = if self.outline.is_some() { - self.pos + Vec2::splat(1) - } else { - self.pos - }; - - for (index, line) in lines.enumerate().take(inner_size.y as usize) { - if !line.is_empty() { - queue!( - io::stdout(), - cursor::MoveTo( - corner_pos.x + ((inner_size.x - line.chars().count() as u16) / 2), - corner_pos.y + index as u16, - ), - style::Print(line), - ) - .unwrap(); - } - } +fn get_lines_iter( + size: Vec2, + text: &str, + align: TextAlignV, +) -> impl Iterator> { + enum LinesIter<'a> { + Top(std::iter::Take>), + Other(std::vec::IntoIter>, usize), } - - fn draw_text_right_align<'a>(&self, lines: impl Iterator>) { - let inner_size = self.inner_size(); - let corner_pos = { - let outer_pos = self.pos.map_x(|x| x + self.size.x); - if self.outline.is_some() { - Vec2::new(outer_pos.x - 1, outer_pos.y + 1) - } else { - outer_pos - } - }; - - for (index, line) in lines.enumerate().take(inner_size.y as usize) { - if !line.is_empty() { - queue!( - io::stdout(), - cursor::MoveTo( - corner_pos.x - line.chars().count() as u16, - corner_pos.y + index as u16 - ), - style::Print(line), - ) - .unwrap(); + impl<'a> Iterator for LinesIter<'a> { + type Item = Cow<'a, str>; + + fn next(&mut self) -> Option { + match self { + LinesIter::Top(iter) => iter.next(), + LinesIter::Other(iter, offset) => { + if *offset > 0 { + *offset -= 1; + Some(Cow::Borrowed("")) + } else { + iter.next() + } + } } } } - /// Draws just the text of this, replacing the previous text - /// Behavior is unspecified when the text align used for `old_text` is - /// different from the current text align of this - /// - /// # Panics - /// - /// Panics if size is not at least 5x3 (outlined) or at least 3x1 (no outline) - pub fn overwrite_text(&self, old_text: &str, new_text: &str) -> &Self { - let old_lines = self.get_lines_iter(old_text); - let new_lines = self.get_lines_iter(new_text); - queue!(io::stdout(), style::SetForegroundColor(self.content_color)).unwrap(); - - match self.text_align_h { - TextAlignH::Left => self.overwrite_text_left_align(old_lines, new_lines), - TextAlignH::Center => self.overwrite_text_center_align(old_lines, new_lines), - TextAlignH::Right => self.overwrite_text_right_align(old_lines, new_lines), + match align { + TextAlignV::Top => { + LinesIter::Top(WordWrap::new(text, size.x as usize).take(size.y as usize)) + } + _ => { + let lines = { + let mut lines = WordWrap::new(text, size.x as usize); + let mut vec = Vec::from_iter(lines.by_ref().take(size.y as usize)); + if lines.next().is_some() { + if let Some(line) = vec.last_mut() { + let line = line.to_mut(); + let mut len = line.chars().count(); + while len > (size.x - 3) as usize { + line.pop(); + len -= 1; + } + line.push_str("..."); + } + } + vec + }; + let len = lines.len(); + LinesIter::Other( + lines.into_iter(), + match align { + TextAlignV::Top => unreachable!(), + TextAlignV::Center => (size.y as usize).saturating_sub(len) / 2, + TextAlignV::Bottom => (size.y as usize).saturating_sub(len), + }, + ) } - self } +} - fn overwrite_text_left_align<'a>( - &self, - old_lines: impl Iterator>, - new_lines: impl Iterator>, - ) { - let inner_size = self.inner_size(); - let corner_pos = if self.outline.is_some() { - self.pos + Vec2::splat(1) - } else { - self.pos - }; - - let old_lines = old_lines.take(inner_size.y as usize); - let mut new_lines = new_lines.take(inner_size.y as usize); - - queue!( - io::stdout(), - corner_pos.move_to(), - style::SetForegroundColor(self.content_color), - style::SetAttributes(self.attributes) - ) - .unwrap(); - for old_line in old_lines { - let old_line_len = old_line.chars().count(); - if let Some(new_line) = new_lines.next().filter(|l| !l.is_empty()) { - let extra_len = old_line_len - .checked_sub(new_line.chars().count()) - .unwrap_or_default(); - queue!( - io::stdout(), - style::Print(new_line), - style::Print(Repeat(' ', extra_len as u16)), - cursor::MoveDown(1), - cursor::MoveToColumn(corner_pos.x) - ) - .unwrap(); - } else { - queue!( - io::stdout(), - style::Print(Repeat(' ', old_line_len as u16)), - cursor::MoveDown(1), - cursor::MoveToColumn(corner_pos.x) - ) - .unwrap(); - } - } - for line in new_lines { +pub fn draw_text(dims: Rect, color: Color, text: &str, align: TextAlign) { + let lines = get_lines_iter(dims.size, text, align.v); + queue!(io::stdout(), style::SetForegroundColor(color)).unwrap(); + for (index, line) in lines.enumerate() { + let line = &line; + if !line.is_empty() { queue!( io::stdout(), + cursor::MoveTo( + dims.pos.x + align.h.padding_for(line, dims.size.x), + dims.pos.y + index as u16 + ), style::Print(line), - cursor::MoveDown(1), - cursor::MoveToColumn(corner_pos.x) ) .unwrap(); } } +} - fn overwrite_text_center_align<'a>( - &self, - old_lines: impl Iterator>, - new_lines: impl Iterator>, - ) { - let inner_size = self.inner_size(); - let corner_pos = if self.outline.is_some() { - self.pos + Vec2::splat(1) - } else { - self.pos - }; - - let old_lines = old_lines.take(inner_size.y as usize); - let mut new_lines = new_lines.take(inner_size.y as usize); - let mut index = 0; - - for old_line in old_lines { - let old_line_len = old_line.chars().count(); - if let Some(new_line) = new_lines.next().filter(|l| !l.is_empty()) { - let new_line_len = new_line.chars().count(); - if new_line_len >= old_line_len { - queue!( - io::stdout(), - cursor::MoveTo( - corner_pos.x + ((inner_size.x - new_line_len as u16) / 2), - corner_pos.y + index as u16, - ), - style::Print(new_line), - ) - .unwrap(); - } else { - let old_line_start = (inner_size.x - old_line_len as u16) / 2; - let old_line_end = old_line_start + inner_size.x; - let new_line_start = (inner_size.x - new_line_len as u16) / 2; - let new_line_end = new_line_start + inner_size.x + 1; - queue!( - io::stdout(), - cursor::MoveTo(corner_pos.x + old_line_start, corner_pos.y + index), - style::Print(Repeat(' ', new_line_start - old_line_start)), - style::Print(new_line), - style::Print(Repeat( - ' ', - (new_line_end - old_line_end).min( - inner_size.x - - (new_line_start - old_line_start) - - new_line_len as u16 - ) - )) - ) - .unwrap(); - } - } else if !old_line.is_empty() { - queue!( - io::stdout(), - cursor::MoveTo( - corner_pos.x + ((inner_size.x - old_line_len as u16) / 2), - corner_pos.y + index as u16, - ), - style::Print(Repeat(' ', old_line_len as u16)), - ) - .unwrap(); - } - index += 1; - } - - for line in new_lines { - if !line.is_empty() { - queue!( - io::stdout(), - cursor::MoveTo( - corner_pos.x + ((inner_size.x - line.chars().count() as u16) / 2), - corner_pos.y + index as u16, - ), - style::Print(line), - ) - .unwrap(); - } - index += 1; - } - } - - fn overwrite_text_right_align<'a>( - &self, - old_lines: impl Iterator>, - new_lines: impl Iterator>, - ) { - let inner_size = self.inner_size(); - let corner_pos = { - let outer_pos = self.pos.map_x(|x| x + self.size.x); - if self.outline.is_some() { - Vec2::new(outer_pos.x - 1, outer_pos.y + 1) - } else { - outer_pos - } - }; +pub fn overwrite_text( + dims: Rect, + color: Color, + old_text: &str, + old_align: TextAlign, + new_text: &str, + new_align: TextAlign, + always_overwrite: bool, +) { + let mut old_lines = get_lines_iter(dims.size, old_text, old_align.v); + let mut new_lines = get_lines_iter(dims.size, new_text, new_align.v); + queue!(io::stdout(), style::SetForegroundColor(color)).unwrap(); + for y in dims.pos.y..dims.pos.y + dims.size.y { + match ( + &old_lines.next().filter(|s| !s.is_empty()), + &new_lines.next().filter(|s| !s.is_empty()), + ) { + (None, None) => {} + (None, Some(new_line)) => queue!( + io::stdout(), + cursor::MoveTo( + dims.pos.x + new_align.h.padding_for(new_line, dims.size.x), + y + ), + style::Print(new_line.trim_end()), + ) + .unwrap(), + (Some(old_line), None) => queue!( + io::stdout(), + cursor::MoveTo( + dims.pos.x + old_align.h.padding_for(old_line.trim_end(), dims.size.x), + y + ), + style::Print(Repeat(' ', old_line.chars().count() as u16)), + ) + .unwrap(), + (Some(old_line), Some(new_line)) => { + let old_line = old_line.trim_end(); + let new_line = new_line.trim_end(); + if always_overwrite || old_line != new_line { + let old_pad = old_align.h.padding_for(old_line, dims.size.x); + let new_pad = new_align.h.padding_for(new_line, dims.size.x); + if new_pad > old_pad { + queue!( + io::stdout(), + cursor::MoveTo(dims.pos.x + old_pad, y), + style::Print(Repeat(' ', new_pad - old_pad)) + ) + .unwrap(); + } else { + queue!(io::stdout(), cursor::MoveTo(dims.pos.x + new_pad, y)).unwrap(); + } + queue!(io::stdout(), style::Print(new_line)).unwrap(); - let old_lines = old_lines.take(inner_size.y as usize); - let mut new_lines = new_lines.take(inner_size.y as usize); - let mut index = 0; - - for old_line in old_lines { - let old_line_len = old_line.chars().count(); - if let Some(new_line) = new_lines.next().filter(|l| !l.is_empty()) { - let new_line_len = new_line.chars().count(); - if new_line_len >= old_line_len { - queue!( - io::stdout(), - cursor::MoveTo(corner_pos.x - new_line_len as u16, corner_pos.y + index), - style::Print(new_line), - ) - .unwrap(); - } else { - queue!( - io::stdout(), - cursor::MoveTo(corner_pos.x - old_line_len as u16, corner_pos.y + index), - style::Print(Repeat(' ', (old_line_len - new_line_len) as u16)), - style::Print(new_line), - ) - .unwrap(); + let old_len = old_pad + old_line.chars().count() as u16; + let new_len = new_pad + new_line.chars().count() as u16; + if old_len > new_len { + queue!(io::stdout(), style::Print(Repeat(' ', old_len - new_len))).unwrap(); + } } - } else { - queue!( - io::stdout(), - cursor::MoveTo(corner_pos.x - old_line_len as u16, corner_pos.y + index), - style::Print(Repeat(' ', old_line_len as u16)), - ) - .unwrap(); } - index += 1; } - for line in new_lines { - if !line.is_empty() { - queue!( - io::stdout(), - cursor::MoveTo( - corner_pos.x - line.chars().count() as u16, - corner_pos.y + index as u16 - ), - style::Print(line), - ) - .unwrap(); - } - index += 1; - } - } - - pub fn inner_size(&self) -> Vec2 { - if self.outline.is_some() { - self.size - Vec2::splat(2) - } else { - self.size - } - } - - pub fn new() -> Self { - Self { - pos: Vec2::splat(0), - size: Vec2::new(5, 3), - outline: Some(BoxOutline::LIGHT), - text_align_h: TextAlignH::Center, - text_align_v: TextAlignV::Center, - outline_color: Color::White, - content_color: Color::White, - attributes: Attributes::default(), - } - } - - builder_impl::field!(pub pos(pos: Vec2)); - builder_impl::field!(pub x(pos.x: u16)); - builder_impl::field!(pub y(pos.y: u16)); - - builder_impl::field!(pub size(size: Vec2)); - builder_impl::field!(pub width(size.x: u16)); - builder_impl::field!(pub height(size.y: u16)); - - builder_impl::field!(pub outline(outline: Option)); - - builder_impl::field!(pub text_align_h(text_align_h: TextAlignH)); - builder_impl::field!(pub text_align_v(text_align_v: TextAlignV)); - - builder_impl::field!(pub outline_color(outline_color: Color)); - builder_impl::field!(pub content_color(content_color: Color)); - pub fn color(&mut self, color: Color) -> &mut Self { - self.outline_color = color; - self.content_color = color; - self - } - - builder_impl::field!(pub attributes(attributes: Attributes)); - pub fn set_attribute(&mut self, attribute: Attribute) -> &mut Self { - self.attributes.set(attribute); - self - } - pub fn unset_attribute(&mut self, attribute: Attribute) -> &mut Self { - self.attributes.unset(attribute); - self - } - pub fn toggle_attribute(&mut self, attribute: Attribute) -> &mut Self { - self.attributes.toggle(attribute); - self } } +/// (total, fract) #[derive(Debug, Clone, Copy)] -pub struct BoxOutline { - tl: char, - tr: char, - bl: char, - br: char, - h: char, - v: char, -} - -#[allow(dead_code)] -impl BoxOutline { - pub const LIGHT: Self = Self { - tl: '┌', - tr: '┐', - bl: '└', - br: '┘', - h: '─', - v: '│', - }; - - pub const HEAVY: Self = Self { - tl: '┏', - tr: '┓', - bl: '┗', - br: '┛', - h: '━', - v: '┃', - }; - - pub const DOUBLE: Self = Self { - tl: '╔', - tr: '╗', - bl: '╚', - br: '╝', - h: '═', - v: '║', - }; - - pub const ERASE: Self = Self { - tl: ' ', - tr: ' ', - bl: ' ', - br: ' ', - h: ' ', - v: ' ', - }; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum TextAlignH { - Left, - Center, - Right, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum TextAlignV { - Top, - Center, - Bottom, +pub struct Proportion>(pub T, pub T); + +impl> fmt::Display for Proportion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let total = self.0.into(); + let fract = self.1.into(); + let frac_str; + let (frac_str, is_nonneg) = if total > f64::EPSILON { + let frac = (fract / total) * 100.0; + frac_str = format!("{frac:.0}%"); + (frac_str.as_str(), frac >= 0.0) + } else { + ("NaN", true) + }; + f.pad_integral(is_nonneg, "", frac_str) + } } diff --git a/src/output/text_box.rs b/src/output/text_box.rs new file mode 100644 index 0000000..1987675 --- /dev/null +++ b/src/output/text_box.rs @@ -0,0 +1,630 @@ +use std::{array, io}; + +use crossterm::{ + cursor, queue, + style::{self, Color}, +}; + +use crate::{ + output::Repeat, + vec2::{Rect, Vec2}, +}; + +use super::TextAlign; + +#[derive(Debug)] +pub struct TextBox> { + dims: Rect, + outline_type: Option, + outline_color: Color, + text: Option, + text_align: TextAlign, + text_color: Color, +} + +impl> TextBox { + pub fn new(dims: Rect) -> Self { + Self { + dims: Self::make_valid_dims(dims), + outline_type: None, + outline_color: Color::White, + text: None, + text_align: TextAlign::CENTER, + text_color: Color::White, + } + } + + fn make_valid_dims(mut dims: Rect) -> Rect { + dims.size.x = dims.size.x.max(5); + dims.size.y = dims.size.y.max(3); + dims + } + + pub fn from_fn(dims: Rect, f: impl FnOnce(&mut TextBoxUpdater)) -> TextBox { + let mut this = Self::new(dims); + this.update(f); + this + } + + pub fn update(&mut self, f: impl FnOnce(&mut TextBoxUpdater)) { + let mut updater = TextBoxUpdater { + new_text: None, + text_color_changed: false, + new_text_align: self.text_align, + redraw_text: false, + redraw_outline: false, + inner: self, + }; + f(&mut updater); + let TextBoxUpdater { + inner: _, + new_text, + text_color_changed, + new_text_align, + redraw_text, + redraw_outline, + } = updater; + + if redraw_outline { + super::draw_outline( + self.dims, + self.outline_color, + self.outline_type.unwrap_or(OutlineType::ERASE), + ); + } + + if redraw_text { + match (self.text.as_ref(), new_text) { + (None, None) => {} + (None, Some(new_text)) => { + super::draw_text( + self.inner_size(), + self.text_color, + new_text.as_ref().map(AsRef::as_ref).unwrap_or_default(), + self.text_align, + ); + self.text = new_text; + } + (Some(old_text), None) => { + super::overwrite_text( + self.inner_size(), + self.text_color, + old_text.as_ref(), + self.text_align, + "", + TextAlign::TOP_LEFT, + text_color_changed, + ); + self.text = None; + } + (Some(old_text), Some(new_text)) => { + super::overwrite_text( + self.inner_size(), + self.text_color, + old_text.as_ref(), + self.text_align, + new_text.as_ref().map(AsRef::as_ref).unwrap_or_default(), + new_text_align, + text_color_changed, + ); + self.text = new_text; + } + } + self.text_align = new_text_align; + } + } + + /// Moves and redraws this without erasing it's past position first + pub fn force_move_resize(&mut self, new_dims: Rect) { + self.dims = Self::make_valid_dims(new_dims); + if let Some(outline_type) = self.outline_type { + super::draw_outline(self.dims, self.outline_color, outline_type); + } + if let Some(text) = &self.text { + super::draw_text( + self.inner_size(), + self.text_color, + text.as_ref(), + self.text_align, + ) + } + } + + pub fn hide(&mut self) { + if let Some(old_text) = self.text.as_ref() { + super::overwrite_text( + self.inner_size(), + self.text_color, + old_text.as_ref(), + self.text_align, + "", + TextAlign::TOP_LEFT, + true, + ); + } + if self.outline_type.is_some() { + super::draw_outline(self.dims, self.outline_color, OutlineType::ERASE); + } + } + + fn inner_size(&self) -> Rect { + self.dims.shrink_centered(Vec2::splat(1)) + } + + pub fn get_text(&self) -> &Option { + &self.text + } +} + +#[derive(Debug)] +pub struct TextBoxUpdater<'a, S: AsRef> { + inner: &'a mut TextBox, + new_text: Option>, + text_color_changed: bool, + new_text_align: TextAlign, + redraw_text: bool, + redraw_outline: bool, +} + +impl<'a, S: AsRef> TextBoxUpdater<'a, S> { + pub fn set_text(&mut self, text: S) -> &mut Self { + match &self.inner.text { + Some(old_text) => self.redraw_text |= old_text.as_ref() != text.as_ref(), + None => self.redraw_text = true, + } + self.new_text = Some(Some(text)); + self + } + + pub fn clear_text(&mut self) -> &mut Self { + self.new_text = Some(None); + self.redraw_text |= self.inner.text.is_some(); + self + } + + pub fn set_text_color(&mut self, color: Color) -> &mut Self { + let changed = !set_and_compare(&mut self.inner.text_color, color); + self.text_color_changed |= changed; + self.redraw_text |= changed; + self + } + + pub fn set_outline(&mut self, outline: OutlineType) -> &mut Self { + self.redraw_outline |= !set_and_compare(&mut self.inner.outline_type, Some(outline)); + self + } + + pub fn add_outline(&mut self, outline: OutlineType) -> &mut Self { + if self.inner.outline_type.is_none() { + self.set_outline(outline) + } else { + self + } + } + + pub fn clear_outline(&mut self) -> &mut Self { + self.redraw_outline |= !set_and_compare(&mut self.inner.outline_type, None); + self + } + + pub fn set_outline_color(&mut self, color: Color) -> &mut Self { + self.redraw_outline |= !set_and_compare(&mut self.inner.outline_color, color); + self + } + + pub fn set_color(&mut self, color: Color) -> &mut Self { + self.set_text_color(color).set_outline_color(color) + } +} + +/// Sets `dst` to `new`, and returns true if they compare equal +fn set_and_compare(dst: &mut T, new: T) -> bool { + let flag = *dst == new; + *dst = new; + flag +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutlineType { + pub tl: char, + pub tr: char, + pub bl: char, + pub br: char, + pub h: char, + pub v: char, +} + +#[allow(dead_code)] +impl OutlineType { + pub const LIGHT: Self = Self { + tl: '┌', + tr: '┐', + bl: '└', + br: '┘', + h: '─', + v: '│', + }; + + pub const HEAVY: Self = Self { + tl: '┏', + tr: '┓', + bl: '┗', + br: '┛', + h: '━', + v: '┃', + }; + + pub const DOUBLE: Self = Self { + tl: '╔', + tr: '╗', + bl: '╚', + br: '╝', + h: '═', + v: '║', + }; + + pub const ERASE: Self = Self { + tl: ' ', + tr: ' ', + bl: ' ', + br: ' ', + h: ' ', + v: ' ', + }; +} + +#[derive(Debug)] +pub struct MultiTextBox, const ITEMS: usize> { + /// The position of the top left corner border + corner_pos: Vec2, + /// The inner size of each text box + item_size: Vec2, + outline_type: Option, + outline_color: Color, + items: [MultiTextBoxItem; ITEMS], +} + +#[derive(Debug, Clone)] +struct MultiTextBoxItem> { + text: Option, + align: TextAlign, + color: Color, +} + +impl, const ITEMS: usize> MultiTextBox { + pub fn new(dims: Rect) -> Self { + let dims = Self::inner_dims_from_size(dims); + Self { + corner_pos: dims.pos, + item_size: dims.size, + outline_type: None, + outline_color: Color::White, + items: [(); ITEMS].map(|()| MultiTextBoxItem { + text: None, + align: TextAlign::CENTER, + color: Color::White, + }), + } + } + + fn inner_dims_from_size(dims: Rect) -> Rect { + let line_sizes = Vec2::new(ITEMS as u16 + 1, 2); + + let size_without_borders = dims.size.join(line_sizes, u16::saturating_sub); + let box_size = size_without_borders / Vec2::new(ITEMS as u16, 1); + let box_size = box_size.join(Vec2::new(3, 1), Ord::max); + + let inaccuracy = dims.size.join( + box_size * Vec2::new(ITEMS as u16, 1) + line_sizes, + u16::saturating_sub, + ); + let corner_pos = inaccuracy / Vec2::splat(2) + dims.pos; + + Rect { + size: box_size, + pos: corner_pos, + } + } + + pub fn update(&mut self, f: impl FnOnce(&mut MultiTextBoxUpdater)) { + let mut updater = MultiTextBoxUpdater { + items: array::from_fn(|i| MultiTextBoxItemChanges { + new_text: None, + text_color_changed: false, + new_align: self.items[i].align, + redraw: false, + }), + redraw_outline: false, + inner: self, + }; + f(&mut updater); + let MultiTextBoxUpdater { + inner: _, + items: new_items, + redraw_outline, + } = updater; + + if redraw_outline { + draw_multi_outline( + self.corner_pos, + self.item_size, + ITEMS as u16, + self.outline_color, + self.outline_type.unwrap_or(MultiOutlineType::ERASE), + ); + } + + for ( + index, + ( + MultiTextBoxItemChanges { + new_text, + text_color_changed, + new_align, + redraw, + }, + item, + ), + ) in new_items.into_iter().zip(self.items.iter_mut()).enumerate() + { + if redraw { + let dims = Rect { + pos: Vec2 { + x: self.corner_pos.x + 1 + ((self.item_size.x + 1) * index as u16), + y: self.corner_pos.y + 1, + }, + size: self.item_size, + }; + match (item.text.as_ref(), new_text) { + (None, None) => {} + (None, Some(text)) => { + super::draw_text( + dims, + item.color, + text.as_ref().map(AsRef::as_ref).unwrap_or_default(), + item.align, + ); + item.text = text; + } + (Some(text), None) => { + super::draw_text(dims, item.color, text.as_ref(), item.align); + } + (Some(old_text), Some(new_text)) => { + super::overwrite_text( + dims, + item.color, + old_text.as_ref(), + item.align, + new_text.as_ref().map(AsRef::as_ref).unwrap_or_default(), + new_align, + text_color_changed, + ); + item.text = new_text; + } + } + item.align = new_align; + } + } + } + + /// Moves and redraws this without erasing it's past position first + pub fn force_move_resize(&mut self, new_dims: Rect) { + let new_dims = Self::inner_dims_from_size(new_dims); + self.corner_pos = new_dims.pos; + self.item_size = new_dims.size; + if let Some(outline_type) = self.outline_type { + draw_multi_outline( + self.corner_pos, + self.item_size, + ITEMS as u16, + self.outline_color, + outline_type, + ); + } + let mut selected_pos = new_dims.pos + Vec2::splat(1); + for item in &self.items { + if let Some(text) = &item.text { + super::draw_text( + Rect { + pos: selected_pos, + size: self.item_size, + }, + item.color, + text.as_ref(), + item.align, + ); + }; + selected_pos.x += self.item_size.x + 1; + } + } + + pub fn hide(&mut self) { + self.update(|updater| { + updater.clear_outline().foreach_text(|_, mut text| { + text.clear(); + }); + }); + } + + pub fn text(&self, index: usize) -> Option<&S> { + self.items[index].text.as_ref() + } +} + +fn draw_multi_outline( + corner_pos: Vec2, + item_size: Vec2, + item_count: u16, + color: Color, + typ: MultiOutlineType, +) { + queue!( + io::stdout(), + corner_pos.move_to(), + style::SetForegroundColor(color), + style::Print(typ.tl), + style::Print(Repeat(typ.h, item_size.x)), + ) + .unwrap(); + for _ in 1..item_count { + queue!( + io::stdout(), + style::Print(typ.join_top), + style::Print(Repeat(typ.h, item_size.x)), + ) + .unwrap(); + } + queue!(io::stdout(), style::Print(typ.tr)).unwrap(); + + for y in (corner_pos.y + 1)..(corner_pos.y + 1 + item_size.y) { + queue!( + io::stdout(), + cursor::MoveTo(corner_pos.x, y), + style::Print(typ.v), + cursor::MoveRight(item_size.x), + ) + .unwrap(); + for _ in 1..item_count { + queue!( + io::stdout(), + style::Print(typ.inner_v), + cursor::MoveRight(item_size.x), + ) + .unwrap(); + } + queue!(io::stdout(), style::Print(typ.v)).unwrap(); + } + + queue!( + io::stdout(), + cursor::MoveTo(corner_pos.x, corner_pos.y + item_size.y + 1), + style::Print(typ.bl), + style::Print(Repeat(typ.h, item_size.x)), + ) + .unwrap(); + for _ in 1..item_count { + queue!( + io::stdout(), + style::Print(typ.join_bot), + style::Print(Repeat(typ.h, item_size.x)), + ) + .unwrap(); + } + queue!(io::stdout(), style::Print(typ.br)).unwrap(); +} + +#[derive(Debug)] +pub struct MultiTextBoxUpdater<'a, S: AsRef, const ITEMS: usize> { + inner: &'a mut MultiTextBox, + items: [MultiTextBoxItemChanges; ITEMS], + redraw_outline: bool, +} + +#[derive(Debug)] +pub struct MultiTextBoxItemChanges> { + new_text: Option>, + text_color_changed: bool, + new_align: TextAlign, + redraw: bool, +} + +#[derive(Debug)] +pub struct MultiTextBoxItemUpdater<'a, S: AsRef> { + item: &'a mut MultiTextBoxItem, + changes: &'a mut MultiTextBoxItemChanges, +} + +impl<'a, S: AsRef, const ITEMS: usize> MultiTextBoxUpdater<'a, S, ITEMS> { + pub fn set_outline(&mut self, outline: MultiOutlineType) -> &mut Self { + self.redraw_outline |= !set_and_compare(&mut self.inner.outline_type, Some(outline)); + self + } + + pub fn clear_outline(&mut self) -> &mut Self { + self.redraw_outline |= !set_and_compare(&mut self.inner.outline_type, None); + self + } + + pub fn text(&mut self, index: usize) -> MultiTextBoxItemUpdater { + MultiTextBoxItemUpdater { + item: &mut self.inner.items[index], + changes: &mut self.items[index], + } + } + + pub fn foreach_text( + &mut self, + mut f: impl FnMut(usize, MultiTextBoxItemUpdater), + ) -> &mut Self { + for i in 0..ITEMS { + f(i, self.text(i)); + } + self + } +} + +impl<'a, S: AsRef> MultiTextBoxItemUpdater<'a, S> { + pub fn set(&mut self, text: S) -> &mut Self { + match &self.item.text { + Some(old_text) => self.changes.redraw |= old_text.as_ref() != text.as_ref(), + None => self.changes.redraw = true, + } + self.changes.new_text = Some(Some(text)); + self + } + + pub fn clear(&mut self) -> &mut Self { + self.changes.redraw |= self.item.text.is_some(); + self.changes.new_text = Some(None); + self + } + + pub fn set_color(&mut self, color: Color) -> &mut Self { + let changed = !set_and_compare(&mut self.item.color, color); + self.changes.text_color_changed |= changed; + self.changes.redraw |= changed; + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MultiOutlineType { + tl: char, + tr: char, + bl: char, + br: char, + h: char, + v: char, + + join_top: char, + join_bot: char, + inner_v: char, +} + +impl MultiOutlineType { + pub const DOUBLE_LIGHT: Self = Self { + tl: '╔', + tr: '╗', + bl: '╚', + br: '╝', + h: '═', + v: '║', + + join_top: '╤', + join_bot: '╧', + inner_v: '│', + }; + + pub const ERASE: Self = Self { + tl: ' ', + tr: ' ', + bl: ' ', + br: ' ', + h: ' ', + v: ' ', + + join_top: ' ', + join_bot: ' ', + inner_v: ' ', + }; +} diff --git a/src/output/word_wrap.rs b/src/output/word_wrap.rs index 4fb2442..38bf873 100644 --- a/src/output/word_wrap.rs +++ b/src/output/word_wrap.rs @@ -1,5 +1,6 @@ use std::{borrow::Cow, iter::FusedIterator, mem}; +#[derive(Debug)] pub struct WordWrap<'a> { text: &'a str, max_length: usize, @@ -13,6 +14,10 @@ impl<'a> WordWrap<'a> { assert!(max_length >= 2); Self { text, max_length } } + + pub fn remaining_text_len(&self) -> usize { + self.text.len() + } } impl<'a> Iterator for WordWrap<'a> { diff --git a/src/study.rs b/src/study.rs index 3181f7f..d8166cc 100644 --- a/src/study.rs +++ b/src/study.rs @@ -1 +1,2 @@ pub mod flashcards; +pub mod learn; diff --git a/src/study/flashcards.rs b/src/study/flashcards.rs index 2a84640..4be2853 100644 --- a/src/study/flashcards.rs +++ b/src/study/flashcards.rs @@ -1,20 +1,26 @@ -use std::path::PathBuf; +use std::{ + io::{self, Write}, + iter, + path::PathBuf, +}; use argh::FromArgs; use crossterm::{ event::{self, Event}, - terminal, + queue, + terminal::{self, ClearType}, }; use crate::{ flashcards::{Set, Side}, load_set, - output::TerminalSettings, - vec2::Vec2, + output::{ + text_box::{OutlineType, TextBox}, + TerminalSettings, + }, + vec2::{Rect, Vec2}, }; -mod grid; - #[derive(Debug, FromArgs)] #[argh(subcommand, name = "flashcards")] /// Study with some classic flashcards! @@ -31,13 +37,11 @@ impl Entry { pub fn run(self) { let set = load_set!(&self.set); let mut scroll_dst = 0u16; + let mut selected_card = Vec2::ZERO; let card_count = self.card_count.unwrap_or_else(|| Vec2::splat(1)); let cards = set.cards; let mut sides = vec![Side::Term; cards.len()]; - let term_size: Vec2<_> = terminal::size() - .expect("unable to get terminal size") - .into(); let mut term_settings = TerminalSettings::new(); term_settings @@ -45,80 +49,241 @@ impl Entry { .hide_cursor() .enable_raw_mode(); - let mut grid = grid::FlashcardGrid::new(card_count); - grid.fill_from_text(cards.iter().map(|card| card[Side::Term].display())) - .size_to(term_size); + let mut visible_cards = Vec::with_capacity(card_count.area() as usize); + { + let term_size: Vec2<_> = terminal::size() + .expect("unable to get terminal size") + .into(); + let card_size = term_size / card_count; + let offset = (term_size - (card_size * card_count)) / Vec2::splat(2); + let mut pos = Vec2::splat(0); + + visible_cards.push(TextBox::from_fn( + Rect { + pos: offset, + size: card_size, + }, + |updater| { + updater.set_color(Side::Term.color()); + if let Some(card) = cards.get(0) { + updater + .set_outline(OutlineType::DOUBLE) + .set_text(card[Side::Term].display().clone()); + } + }, + )); + + let mut index = 0; + visible_cards.extend(iter::from_fn(|| { + let make_text_box = |pos, index: usize| { + TextBox::from_fn( + Rect { + pos: pos * card_size + offset, + size: card_size, + }, + |updater| { + updater.set_color(Side::Term.color()); + if let Some(card) = cards.get(index) { + updater + .set_outline(OutlineType::HEAVY) + .set_text(card[Side::Term].display().clone()); + } + }, + ) + }; + + index += 1; + pos.x += 1; + if pos.x < card_count.x { + Some(make_text_box(pos, index)) + } else { + pos.x = 0; + pos.y += 1; + (pos.y < card_count.y).then(|| make_text_box(pos, index)) + } + })) + } + io::stdout().flush().unwrap(); loop { match event::read().expect("Unable to read event") { Event::Resize(x, y) => { - grid.size_to(Vec2::new(x, y)); + queue!(io::stdout(), terminal::Clear(ClearType::All)).unwrap(); + let term_size = Vec2 { x, y }; + let card_size = term_size / card_count; + let offset = (term_size - (card_size * card_count)) / Vec2::splat(2); + let mut pos = Vec2::splat(0); + for card in &mut visible_cards { + card.force_move_resize(Rect { + pos: pos * card_size + offset, + size: card_size, + }); + pos.x += 1; + if pos.x >= card_count.x { + pos.x = 0; + pos.y += 1; + } + } + io::stdout().flush().unwrap(); } - crate::up!() => grid.update(|grid| { - if let Some(y) = grid.selected().y.checked_sub(1) { - grid.set_selected(Vec2::new(grid.selected().x, y)); + crate::up!() => { + if selected_card.y > 0 { + visible_cards[selected_card.index_row_major(card_count.x as usize)].update( + |updater| { + updater.set_outline(OutlineType::HEAVY); + }, + ); + selected_card.y -= 1; + visible_cards[selected_card.index_row_major(card_count.x as usize)].update( + |updater| { + updater.set_outline(OutlineType::DOUBLE); + }, + ); } else if scroll_dst > 0 { scroll_dst -= 1; - grid.fill_from_cards( - cards - .iter() - .zip(sides.iter()) - .map(|(card, side)| (card[*side].display(), *side)) - .skip((scroll_dst * grid.card_count().x) as usize), - ); + for i in (card_count.x..card_count.area()).rev() { + let new_text = visible_cards[(i - card_count.x) as usize] + .get_text() + .clone(); + visible_cards[i as usize].update(|updater| { + updater + .set_color( + sides[(i + scroll_dst * card_count.x) as usize].color(), + ) + .add_outline(OutlineType::HEAVY) + .set_text(new_text.unwrap()); + }); + } + for x in (0..card_count.x).rev() { + let index = + Vec2::new(x, scroll_dst).index_row_major(card_count.x as usize); + let side = sides[index]; + let new_text = cards[index][side].display().clone(); + visible_cards[x as usize].update(|updater| { + updater + .set_color(side.color()) + .add_outline(OutlineType::HEAVY) + .set_text(new_text); + }) + } } - }), - crate::down!() => grid.update(|grid| { - let new_selected = grid.selected() + Vec2::new(0, 1); - if (new_selected + Vec2::new(0, scroll_dst)) - .index_row_major(grid.card_count().x as usize) - < cards.len() - { - if new_selected.y < grid.card_count().y { - grid.set_selected(new_selected); - } else { + io::stdout().flush().unwrap(); + } + crate::down!() => { + if selected_card.y < card_count.y - 1 { + if (selected_card + Vec2::y(scroll_dst + 1)) + .index_row_major(card_count.x as usize) + < cards.len() + { + visible_cards[selected_card.index_row_major(card_count.x as usize)] + .update(|updater| { + updater.set_outline(OutlineType::HEAVY); + }); + selected_card.y += 1; + visible_cards[selected_card.index_row_major(card_count.x as usize)] + .update(|updater| { + updater.set_outline(OutlineType::DOUBLE); + }); + } + } else { + let overflow_selected = selected_card + Vec2::Y + Vec2::y(scroll_dst); + let index = overflow_selected.index_row_major(card_count.x as usize); + if index < cards.len() { scroll_dst += 1; - grid.fill_from_cards( - cards - .iter() - .zip(sides.iter()) - .map(|(card, side)| (card[*side].display(), *side)) - .skip((scroll_dst * grid.card_count().x) as usize), - ); + for i in 0..(card_count - Vec2::Y).area() { + let new_text = visible_cards[(i + card_count.x) as usize] + .get_text() + .clone(); + visible_cards[i as usize].update(|updater| { + if let Some(text) = new_text { + updater + .add_outline(OutlineType::HEAVY) + .set_color( + sides[(i + scroll_dst * card_count.x) as usize] + .color(), + ) + .set_text(text); + } else { + updater.clear_outline().clear_text(); + } + }) + } + for x in 0..card_count.x { + let index = Vec2::new(x, scroll_dst + card_count.y - 1) + .index_row_major(card_count.x as usize); + if let Some(side) = sides.get(index) { + let new_text = cards[index][*side].display().clone(); + visible_cards[Vec2::new(x, card_count.y - 1) + .index_row_major(card_count.x as usize)] + .update(|updater| { + updater.set_color(side.color()).set_text(new_text); + }) + } else { + visible_cards[Vec2::new(x, card_count.y - 1) + .index_row_major(card_count.x as usize)] + .hide() + } + } } } - }), - crate::left!() => grid.update(|grid| { - grid.selected_mut().x = grid.selected().x.saturating_sub(1); - }), - crate::right!() => grid.update(|grid| { - let new_selected = grid.selected() + Vec2::new(1, 0); - if (new_selected + Vec2::new(0, scroll_dst)) - .index_row_major(grid.card_count().x as usize) - < cards.len() - && new_selected.x < grid.card_count().x + io::stdout().flush().unwrap(); + } + crate::left!() => { + if selected_card.x > 0 { + visible_cards[selected_card.index_row_major(card_count.x as usize)].update( + |updater| { + updater.set_outline(OutlineType::HEAVY); + }, + ); + selected_card.x -= 1; + visible_cards[selected_card.index_row_major(card_count.x as usize)].update( + |updater| { + updater.set_outline(OutlineType::DOUBLE); + }, + ); + io::stdout().flush().unwrap(); + } + } + crate::right!() => { + if selected_card.x < card_count.x - 1 + && (selected_card + Vec2::new(1, scroll_dst)) + .index_row_major(card_count.x as usize) + < cards.len() { - grid.set_selected(new_selected); + visible_cards[selected_card.index_row_major(card_count.x as usize)].update( + |updater| { + updater.set_outline(OutlineType::HEAVY); + }, + ); + selected_card.x += 1; + visible_cards[selected_card.index_row_major(card_count.x as usize)].update( + |updater| { + updater.set_outline(OutlineType::DOUBLE); + }, + ); + io::stdout().flush().unwrap(); } - }), + } crate::click!() => { - grid.update(|grid| { - let mut selected = grid.selected(); - let width = grid.card_count().x as usize; - let card = (&mut grid[selected]).as_mut().unwrap(); - let new_side = !card.1; - selected.y += scroll_dst; - let index = selected.index_row_major(width); - sides[index] = new_side; - *card = (cards[index][new_side].display(), new_side); - }); + let index = (selected_card + Vec2::y(scroll_dst)) + .index_row_major(card_count.x as usize); + let side = &mut sides[index]; + *side = !*side; + visible_cards[selected_card.index_row_major(card_count.x as usize)].update( + |updater| { + updater + .set_color(side.color()) + .set_text(cards[index][*side].display().clone()); + }, + ); + io::stdout().flush().unwrap(); } Event::Key(_) => break, _ => {} } } - drop(term_settings); + term_settings.clear(); } } diff --git a/src/study/flashcards/grid.rs b/src/study/flashcards/grid.rs deleted file mode 100644 index 0ac6d4b..0000000 --- a/src/study/flashcards/grid.rs +++ /dev/null @@ -1,195 +0,0 @@ -use std::{ - io::{self, Write}, - ops::{Index, IndexMut}, -}; - -use crate::{ - flashcards::Side, - output::{BoxOutline, TextBox}, - vec2::Vec2, -}; - -#[derive(Debug)] -pub struct FlashcardGrid<'a> { - card_count: Vec2, - card_size: Vec2, - offset: Vec2, - selected: Vec2, - /// The cards that can currently be seen. - /// The length of this is equal to `self.card_count.area()` - cards: Vec>, -} - -impl<'a> FlashcardGrid<'a> { - #[must_use] - pub fn new(card_count: Vec2) -> Self { - FlashcardGrid { - card_count, - card_size: Vec2::new(5, 3), - offset: Vec2::ZERO, - selected: Vec2::ZERO, - cards: vec![None; card_count.area() as usize], - } - } - - pub fn fill_from_text(&mut self, cards_iter: impl Iterator) -> &mut Self { - cards_iter - .take(self.card_count.area() as usize) - .map(|text| Some((text, Side::Term))) - .enumerate() - .for_each(|(index, value)| self.cards[index] = value); - self - } - - pub fn fill_from_cards( - &mut self, - cards_iter: impl Iterator, - ) -> &mut Self { - self.cards.clear(); - let area = self.card_count.area() as usize; - self.cards.extend(cards_iter.take(area).map(Some)); - self.cards.resize(area, None); - self - } - - #[must_use] - fn card_printer(&self) -> TextBox { - let mut card_printer = TextBox::new(); - card_printer.text_align_h = crate::output::TextAlignH::Center; - card_printer.text_align_v = crate::output::TextAlignV::Center; - card_printer.size = self.card_size; - card_printer - } - - /// Resizes and prints this - pub fn size_to(&mut self, term_size: Vec2) -> &mut Self { - let card_size = Some(term_size / self.card_count).filter(|s| s.x >= 5 && s.y >= 3); - if let Some(card_size) = card_size { - self.card_size = card_size; - self.offset = (term_size - (self.card_count * card_size)) / Vec2::splat(2); - self.print(); - } else { - self.card_size = Vec2::new(5, 3); - self.offset = Vec2::ZERO; - } - self - } - - fn print_at<'b>(&self, pos: Vec2, printer: &'b mut TextBox) -> &'b mut TextBox { - printer.pos(pos * self.card_size + self.offset) - } - - fn print_card<'b>(&self, pos: Vec2, printer: &'b mut TextBox) -> &'b mut TextBox { - let index = pos.index_row_major(self.card_count.x as usize); - if let Some((text, side)) = self.cards[index] { - self.print_at(pos, printer) - .outline(outline_type(pos == self.selected)) - .color(side.color()) - .draw_outline_and_text(text); - } - printer - } - - pub fn print(&self) -> &Self { - use crossterm::{queue, terminal}; - queue!(io::stdout(), terminal::Clear(terminal::ClearType::All)).unwrap(); - let mut printer = self.card_printer(); - for pos in Vec2::ZERO.positions_between(self.card_count) { - self.print_card(pos, &mut printer); - } - io::stdout().flush().unwrap(); - self - } - - pub fn update(&mut self, f: impl FnOnce(&mut FlashcardGridUpdater<'a, '_>)) { - let old_cards = self.cards.clone(); - let old_selected = self.selected; - let mut updater = FlashcardGridUpdater(self); - f(&mut updater); - - let mut printer = self.card_printer(); - for pos in Vec2::ZERO.positions_between(self.card_count) { - let index = pos.index_row_major(self.card_count.x as usize); - match (old_cards[index], self.cards[index]) { - (Some((old_text, old_side)), Some((text, side))) => { - let color_changed = old_side != side; - let redraw_outline = - ((pos == old_selected) != (pos == self.selected)) || color_changed; - let redraw_text = old_text != text || color_changed; - if redraw_outline || redraw_text { - self.print_at(pos, &mut printer) - .outline(outline_type(pos == self.selected)) - .color(side.color()); - if redraw_outline { - printer.draw_outline(); - } - if redraw_text { - printer.overwrite_text(old_text, text); - } - } - } - (Some((old_text, _)), None) => { - self.print_at(pos, &mut printer) - .outline(Some(BoxOutline::ERASE)) - .draw_outline() - .overwrite_text(old_text, ""); - } - (None, Some(_)) => { - self.print_card(pos, &mut printer); - } - (None, None) => {} - } - } - io::stdout().flush().unwrap(); - } -} - -fn outline_type(selected: bool) -> Option { - Some(match selected { - true => BoxOutline::DOUBLE, - false => BoxOutline::HEAVY, - }) -} - -#[derive(Debug)] -pub struct FlashcardGridUpdater<'a, 'b>(&'b mut FlashcardGrid<'a>); - -impl<'a, 'b> FlashcardGridUpdater<'a, 'b> { - pub fn card_count(&self) -> Vec2 { - self.0.card_count - } - - pub fn selected(&self) -> Vec2 { - self.0.selected - } - - pub fn selected_mut(&mut self) -> &mut Vec2 { - &mut self.0.selected - } - - pub fn set_selected(&mut self, selected: Vec2) { - self.0.selected = selected; - } - - pub fn fill_from_cards( - &mut self, - cards_iter: impl Iterator, - ) -> &mut Self { - self.0.fill_from_cards(cards_iter); - self - } -} - -impl<'a, 'b> Index> for FlashcardGridUpdater<'a, 'b> { - type Output = Option<(&'a str, Side)>; - - fn index(&self, index: Vec2) -> &Self::Output { - &self.0.cards[index.index_row_major(self.0.card_count.x as usize)] - } -} - -impl<'a, 'b> IndexMut> for FlashcardGridUpdater<'a, 'b> { - fn index_mut(&mut self, index: Vec2) -> &mut Self::Output { - &mut self.0.cards[index.index_row_major(self.0.card_count.x as usize)] - } -} diff --git a/src/study/learn.rs b/src/study/learn.rs new file mode 100644 index 0000000..cb77124 --- /dev/null +++ b/src/study/learn.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; + +use argh::FromArgs; +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + terminal, +}; + +use crate::{ + flashcards::Set, + load_set, + output::{self, TerminalSettings}, + vec2::Vec2, +}; + +use self::world::World; + +mod world; + +/// Learn a set +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "learn")] +pub struct Entry { + /// the set to learn + #[argh(positional)] + set: PathBuf, +} + +impl Entry { + pub fn run(self) { + let set = load_set!(&self.set); + if set.cards.is_empty() { + output::write_fatal_error("Set must have at least 1 card to learn"); + return; + } + + let mut term_settings = TerminalSettings::new(); + term_settings + .enable_raw_mode() + .enter_alternate_screen() + .hide_cursor(); + + let mut world = World::new( + terminal::size() + .expect("unable to get terminal size") + .into(), + &set, + term_settings, + ); + + loop { + match event::read().unwrap() { + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers, + .. + }) if modifiers.contains(KeyModifiers::CONTROL) => { + world.print_stats(Some("Exited with ctrl-c")); + return; + } + Event::Resize(x, y) => world.resize(Vec2::new(x, y)), + Event::Key(event) => { + if world.key_pressed(event).is_break() { + break; + } + } + _ => {} + } + } + + world.print_stats(None); + } +} diff --git a/src/study/learn/world.rs b/src/study/learn/world.rs new file mode 100644 index 0000000..e94b827 --- /dev/null +++ b/src/study/learn/world.rs @@ -0,0 +1,478 @@ +use std::{ + fmt::Display, + io::{self, Write}, + ops::{Add, ControlFlow}, + rc::Rc, +}; + +use crossterm::{ + event::{KeyCode, KeyEvent}, + execute, queue, + style::{self, Color, Stylize}, + terminal::{self, ClearType}, +}; + +use crate::{ + flashcards::{Set, Side}, + input::text::TextInput, + output::{ + text_box::{MultiOutlineType, MultiTextBox, OutlineType, TextBox}, + Proportion, TerminalSettings, + }, + study::learn::world::card_list::fail_count::FailCount, + vec2::{Rect, Vec2}, +}; + +use self::{ + card_list::{CardList, StudyType}, + footer::Footer, +}; + +mod card_list; +mod footer; + +thread_local! { + static CORRECT: Rc = Rc::from("Correct! Press any key to continue"); + static TEXT_FAILED: Rc = Rc::from("Incorrect! Shift-Tab: typo, I know this"); +} + +#[derive(Debug)] +pub struct World<'a> { + question_box: TextBox>, + matching_answers_boxes: MultiTextBox, 4>, + text_input: TextInput, + footer: Footer, + card_list: CardList<'a>, + term_settings: TerminalSettings, + matches_made: [u32; 2], + texts_entered: [u32; 2], + typ: Option, +} + +#[derive(Debug)] +enum WorldType { + Matching { + studying: card_list::Token, + failed: bool, + }, + Text { + studying: card_list::Token, + }, + TextFailed { + studying: card_list::Token, + hidden_question: Option>, + }, + WaitingForKeypress, +} + +impl<'a> World<'a> { + #[must_use] + pub fn new(size: Vec2, set: &'a Set, term_settings: TerminalSettings) -> Self { + let card_list = CardList::from_set(set); + + let height_minus_padding = size.y.saturating_sub(7); + let box_height = height_minus_padding / 2; + let bottom_box_y = size.y.saturating_sub(box_height).saturating_sub(3); + + let mut this = World { + question_box: TextBox::from_fn( + Rect { + size: Vec2::new(size.x / 3, box_height), + pos: Vec2::new(size.x / 3, 2), + }, + |updater| { + updater.set_outline(OutlineType::DOUBLE); + }, + ), + matching_answers_boxes: MultiTextBox::new(Rect { + size: Vec2::new(size.x.saturating_sub(8), box_height), + pos: Vec2::new(4, bottom_box_y), + }), + text_input: TextInput::new(Rect { + size: Vec2::new(size.x / 3, box_height), + pos: Vec2::new(size.x / 3, bottom_box_y), + }), + footer: Footer::new(card_list.remaining_unstudied() as u32, size), + card_list, + term_settings, + matches_made: [0, 0], + texts_entered: [0, 0], + typ: None, + }; + + this.study_next(); + + this + } + + pub fn study_next(&mut self) -> bool { + let res = match self.card_list.next_unstudied(None) { + Some(item) => { + let (item, token) = item.tup(); + match item.next_study_type { + StudyType::Matching(_) => { + self.text_input.hide(); + + self.question_box.update(|updater| { + updater + .set_text(item.card[!item.side].display().clone()) + .set_text_color(Color::White); + }); + self.matching_answers_boxes.update(|updater| { + for (i, answer) in self + .card_list + .matching_answers_for(item) + .into_iter() + .enumerate() + { + updater.text(i).set(answer).set_color(Color::White); + } + updater.set_outline(MultiOutlineType::DOUBLE_LIGHT); + }); + + self.typ = Some(WorldType::Matching { + studying: token, + failed: false, + }); + } + StudyType::Text(_) => { + self.matching_answers_boxes.hide(); + + self.question_box.update(|updater| { + updater + .set_text(item.card[!item.side].display().clone()) + .set_text_color(Color::White); + }); + self.text_input.update(|updater| { + updater + .set_outline(OutlineType::DOUBLE) + .clear_text() + .clear_correct_answer(); + }); + self.term_settings.show_cursor(); + self.text_input.go_to_cursor(); + + self.typ = Some(WorldType::Text { studying: token }); + } + } + true + } + None => { + self.typ = None; + false + } + }; + io::stdout().flush().unwrap(); + res + } + + pub fn resize(&mut self, size: Vec2) { + let height_minus_padding = size.y.saturating_sub(7); + let box_height = height_minus_padding / 2; + let bottom_box_y = size.y.saturating_sub(box_height).saturating_sub(3); + + queue!(io::stdout(), terminal::Clear(ClearType::All)).unwrap(); + + self.question_box.force_move_resize(Rect { + size: Vec2::new(size.x / 3, box_height), + pos: Vec2::new(size.x / 3, 2), + }); + + self.matching_answers_boxes.force_move_resize(Rect { + size: Vec2::new(size.x.saturating_sub(8), box_height), + pos: Vec2::new(4, bottom_box_y), + }); + + self.text_input.force_move_resize(Rect { + size: Vec2::new(size.x / 3, box_height), + pos: Vec2::new(size.x / 3, bottom_box_y), + }); + + self.footer.resize(size); + + io::stdout().flush().unwrap(); + } + + pub fn key_pressed(&mut self, event: KeyEvent) -> ControlFlow<()> { + let code = event.code; + match self.typ { + Some(WorldType::Matching { + studying, + ref mut failed, + }) => { + if let KeyCode::Char(key) = code { + if ('1'..='4').contains(&key) { + let index = key as usize - '1' as usize; + let card = &self.card_list[studying]; + let text_color; + + if card.card[card.side].contains( + self.matching_answers_boxes.text(index).unwrap(), + self.card_list.recall_settings(card.side), + ) { + text_color = Color::Green; + let failed = *failed; + self.typ = Some(WorldType::WaitingForKeypress); + if !failed { + self.matches_made[card.side as usize] += 1; + self.card_list.progress(studying, &mut self.footer); + } + + self.question_box.update(|updater| { + updater + .set_text(CORRECT.with(Clone::clone)) + .set_text_color(Color::Green); + }); + } else { + text_color = Color::Red; + if !*failed { + *failed = true; + self.matches_made[card.side as usize] += 1; + self.card_list.fail(studying); + self.card_list.regress(studying, &mut self.footer); + } + }; + + self.matching_answers_boxes.update(|updater| { + updater.text(index).set_color(text_color); + }); + io::stdout().flush().unwrap(); + } + } + ControlFlow::Continue(()) + } + Some(WorldType::Text { studying }) => { + if let Some(answer) = self.text_input.read_input(code) { + let card = &self.card_list[studying]; + if card.card[card.side] + .contains(answer, self.card_list.recall_settings(card.side)) + { + self.texts_entered[card.side as usize] += 1; + self.card_list.progress(studying, &mut self.footer); + self.question_box.update(|updater| { + updater + .set_text(CORRECT.with(Clone::clone)) + .set_text_color(Color::Green); + }); + self.term_settings.hide_cursor(); + + self.typ = Some(WorldType::WaitingForKeypress); + } else { + self.texts_entered[card.side as usize] += 1; + let hidden_question = self.question_box.get_text().clone(); + self.question_box.update(|updater| { + updater + .set_text(TEXT_FAILED.with(Clone::clone)) + .set_text_color(Color::Red); + }); + self.text_input + .correct_answer_is(card.card[card.side].display().clone()); + self.card_list.fail(studying); + self.card_list.regress(studying, &mut self.footer); + self.text_input.go_to_cursor(); + + self.typ = Some(WorldType::TextFailed { + studying, + hidden_question, + }); + } + } else { + self.text_input.go_to_cursor(); + } + io::stdout().flush().unwrap(); + ControlFlow::Continue(()) + } + Some(WorldType::TextFailed { + studying, + ref mut hidden_question, + }) => { + if code == KeyCode::BackTab { + self.card_list.progress(studying, &mut self.footer); + self.card_list.progress(studying, &mut self.footer); + self.term_settings.hide_cursor(); + match self.study_next() { + true => ControlFlow::Continue(()), + false => ControlFlow::Break(()), + } + } else { + if let Some(question) = hidden_question.take() { + self.question_box.update(|updater| { + updater.set_text(question).set_text_color(Color::White); + }) + } + self.text_input.read_input(code); + let card = &self.card_list[studying]; + if card.card[card.side].contains( + self.text_input.get_text(), + self.card_list.recall_settings(card.side), + ) { + self.term_settings.hide_cursor(); + match self.study_next() { + true => ControlFlow::Continue(()), + false => ControlFlow::Break(()), + } + } else { + self.text_input.go_to_cursor(); + io::stdout().flush().unwrap(); + ControlFlow::Continue(()) + } + } + } + Some(WorldType::WaitingForKeypress) => match self.study_next() { + true => ControlFlow::Continue(()), + false => ControlFlow::Break(()), + }, + None => ControlFlow::Break(()), + } + } + + pub fn print_stats(self, error_code: Option<&str>) { + let Self { + mut card_list, + term_settings, + matches_made, + texts_entered, + .. + } = self; + term_settings.clear(); + + if let Some(error_code) = error_code { + println!("{}", error_code.red()); + } + println!("{}", "Stats:".bold()); + println!(" | total | term |definition|"); + + let mut match_fails = [0, 0]; + let mut text_fails = [0, 0]; + let fails = card_list.fails(); + for fail in &*fails { + match_fails[fail.side as usize] += fail.match_fails.count() as u32; + text_fails[fail.side as usize] += fail.text_fails.count() as u32; + } + + #[derive(Debug)] + struct StatsSection { + made: StatsLine, + fails: StatsLine, + } + + #[derive(Debug)] + struct StatsLine { + tag: &'static str, + term: T, + definition: T, + total: T, + } + + impl StatsSection { + fn print(&self) { + self.made.print(); + self.fails.print(); + let fail_prop = StatsLine { + tag: "", + term: Proportion(self.made.term, self.fails.term), + definition: Proportion(self.made.definition, self.fails.definition), + total: Proportion(self.made.total, self.fails.total), + }; + fail_prop.print(); + } + + fn try_print(&self) { + if self.is_used() { + self.print(); + } + } + + fn is_used(&self) -> bool { + self.made.total > 0 + } + } + + impl StatsLine { + fn print(&self) { + let StatsLine { + tag, + term, + definition, + total, + } = self; + println!("{tag:15} |{total:^10}|{term:^10}|{definition:^10}|"); + } + } + + impl> StatsLine { + fn new(tag: &'static str, data: [T; 2]) -> Self { + let term = data[Side::Term as usize]; + let definition = data[Side::Definition as usize]; + Self { + tag, + term, + definition, + total: term + definition, + } + } + + fn add(tag: &'static str, a: &Self, b: &Self) -> Self { + Self { + tag, + term: a.term + b.term, + definition: a.definition + b.definition, + total: a.total + b.total, + } + } + } + + let matches = StatsSection { + made: StatsLine::new("Matches made:", matches_made), + fails: StatsLine::new("Match fails:", match_fails), + }; + matches.try_print(); + + let texts = StatsSection { + made: StatsLine::new("Texts entered:", texts_entered), + fails: StatsLine::new("Text fails:", text_fails), + }; + texts.try_print(); + + if matches.is_used() && texts.is_used() { + let total = StatsSection { + made: StatsLine::add("Total answered:", &matches.made, &texts.made), + fails: StatsLine::add("Total fails:", &matches.fails, &texts.fails), + }; + total.print(); + } + + const COLORS: [Color; 5] = [ + Color::Grey, + Color::Yellow, + Color::Red, + Color::Magenta, + Color::Blue, + ]; + fn get_color(color: usize) -> Color { + *COLORS.get(color - 1).unwrap_or(&Color::Cyan) + } + + let mut fails: Vec<_> = fails + .iter() + .filter_map(|fail| { + (fail.total_fails() > FailCount::ZERO) + .then(|| (fail.total_fails(), fail.card[!fail.side].display())) + }) + .collect(); + fails.sort_unstable_by(|a, b| (a.0.cmp(&b.0)).then_with(|| a.1.cmp(b.1))); + + if matches.is_used() { + println!("\n{}", "Fails:".bold()); + for (count, text) in fails { + execute!( + io::stdout(), + style::SetForegroundColor(get_color(count.count() as usize)), + style::Print(format!(" {text} ({count})\n")), + style::SetForegroundColor(Color::Reset), + ) + .unwrap(); + } + } + } +} diff --git a/src/study/learn/world/card_list.rs b/src/study/learn/world/card_list.rs new file mode 100644 index 0000000..3f332c4 --- /dev/null +++ b/src/study/learn/world/card_list.rs @@ -0,0 +1,339 @@ +use std::{ + ops::{Index, IndexMut}, + rc::Rc, +}; + +use rand::{seq::SliceRandom, Rng}; + +use crate::flashcards::{Flashcard, RecallSettings, Set, Side}; + +use self::fail_count::FailCount; + +use super::footer::{Footer, FooterColor}; + +#[derive(Debug)] +pub struct Item<'a> { + pub card: &'a Flashcard, + pub side: Side, + pub next_study_type: StudyType, + pub footer_color: FooterColor, + pub match_fails: FailCount, + pub text_fails: FailCount, +} + +#[derive(Debug)] +pub struct FailedItem<'a> { + pub card: &'a Flashcard, + pub side: Side, + pub match_fails: FailCount, + pub text_fails: FailCount, +} + +impl<'a> From<&Item<'a>> for FailedItem<'a> { + fn from(item: &Item<'a>) -> Self { + FailedItem { + card: item.card, + side: item.side, + match_fails: item.match_fails, + text_fails: item.text_fails, + } + } +} + +impl<'a> FailedItem<'a> { + pub fn total_fails(&self) -> FailCount { + self.match_fails + self.text_fails + } +} + +/// A token representing an item in a card list +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Token(usize); + +#[derive(Debug)] +pub struct RefToken<'a, 'b> { + list: &'a CardList<'b>, + token: Token, +} + +impl<'a, 'b> RefToken<'a, 'b> { + pub fn token(&self) -> Token { + self.token + } + + pub fn item(&self) -> &Item<'b> { + &self.list[self.token] + } + + pub fn tup(&self) -> (&Item<'b>, Token) { + (self.item(), self.token()) + } +} + +#[derive(Debug)] +pub struct CardList<'a> { + cards: Vec>, + removed: Vec>, + set: &'a Set, +} + +impl<'a> CardList<'a> { + pub fn from_set(set: &'a Set) -> Self { + let term_start = StudyType::first(&set.recall_t); + let definition_start = StudyType::first(&set.recall_d); + let count = [term_start.is_some(), definition_start.is_some()] + .into_iter() + .filter(|b| *b) + .count(); + let mut cards = Vec::with_capacity(count * set.cards.len()); + + let mut extend_cards = |start, side| { + if let Some(next_study_type) = start { + cards.extend(set.cards.iter().map(|card| Item { + card, + side, + next_study_type, + footer_color: FooterColor::Black, + match_fails: FailCount::ZERO, + text_fails: FailCount::ZERO, + })) + } + }; + + extend_cards(term_start, Side::Term); + extend_cards(definition_start, Side::Definition); + + Self { + removed: Vec::new(), + cards, + set, + } + } + + pub fn next_unstudied(&self, last: Option) -> Option> { + if self.cards.is_empty() { + None + } else { + let last = last.map(|s| s.0).unwrap_or(usize::MAX); + let mut index = last; + let mut counter = 0; + while index == last && counter < 12 { + index = rand::thread_rng().gen_range(0..self.cards.len()); + counter += 1; + } + Some(RefToken { + list: self, + token: Token(index), + }) + } + } + + pub fn progress(&mut self, card: Token, footer: &mut Footer) { + let index = card.0; + let card = &self.cards[index]; + let old_color = card.footer_color; + let new_color = match card + .next_study_type + .progress(self.recall_settings(card.side)) + { + (Some(next_study_type), color) => { + let card = &mut self.cards[index]; + card.next_study_type = next_study_type; + card.footer_color = color; + color + } + (None, color) => { + let card = self.cards.swap_remove(index); + if card.match_fails.has_failed() || card.text_fails.has_failed() { + self.removed.push((&card).into()); + } + color + } + }; + footer.r#move(old_color, new_color); + } + + pub fn regress(&mut self, card: Token, footer: &mut Footer) { + let index = card.0; + let card = &self.cards[index]; + let old_color = card.footer_color; + if let Some((next_study_type, new_color)) = card + .next_study_type + .regress(self.recall_settings(card.side)) + { + let card = &mut self.cards[index]; + card.next_study_type = next_study_type; + card.footer_color = new_color; + footer.r#move(old_color, new_color); + } + } + + pub fn fail(&mut self, card: Token) { + let card = &mut self.cards[card.0]; + match card.next_study_type { + StudyType::Matching(_) => card.match_fails.inc(), + StudyType::Text(_) => card.text_fails.inc(), + } + } + + pub fn matching_answers_for(&self, card: &Item<'a>) -> [Rc; 4] { + let mut answers = [None, None, None, None]; + answers[0] = Some(card.card[card.side].display().clone()); + let mut rng = rand::thread_rng(); + for i in 1..4 { + for _ in 0..12 { + answers[i] = Some( + self.set.cards.choose(&mut rng).unwrap()[card.side] + .display() + .clone(), + ); + if !answers[..i].contains(&answers[i]) { + break; + } + } + } + let mut answers = answers.map(Option::unwrap); + answers.shuffle(&mut rng); + answers + } + + pub fn recall_settings(&self, side: Side) -> &RecallSettings { + self.set.recall_settings(side) + } + + pub fn remaining_unstudied(&self) -> usize { + self.cards.len() + } + + pub fn fails(&mut self) -> &mut [FailedItem<'a>] { + self.removed.extend(self.cards.iter().filter_map(|item| { + (item.match_fails.has_failed() || item.text_fails.has_failed()).then_some(item.into()) + })); + &mut self.removed + } +} + +impl<'a> Index for CardList<'a> { + type Output = Item<'a>; + + fn index(&self, index: Token) -> &Self::Output { + &self.cards[index.0] + } +} + +impl<'a> IndexMut for CardList<'a> { + fn index_mut(&mut self, index: Token) -> &mut Self::Output { + &mut self.cards[index.0] + } +} + +/// Progression: +/// ```no_run +/// match (matching, text) { +/// (false, false) => [], +/// (true, false) => [M0, M1], // Black, Yellow +/// (false, true) => [T0, T1], // Black, Yellow +/// (true, true) => [M0, T0, T1], // Black, Red, Yellow +/// } +/// ``` +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +pub enum StudyType { + Matching(bool), + Text(bool), +} + +impl StudyType { + #[rustfmt::skip] + fn first(recall_settings: &RecallSettings) -> Option { + use StudyType::*; + match (recall_settings.matching, recall_settings.text) { + (false, false) => None, + (true, _) => Some(Matching(false)), + (false, true) => Some(Text(false)), + } + } + + #[rustfmt::skip] + fn progress(self, recall_settings: &RecallSettings) -> (Option, FooterColor) { + use StudyType::*; + match (self, recall_settings.matching, recall_settings.text) { + (Matching(false), true, false) => (Some(Matching(true)), FooterColor::Yellow), + (Matching(true), true, false) => (None, FooterColor::Green), + + (Text(false), false, true) => (Some(Text(true)), FooterColor::Yellow), + (Text(true), false, true) => (None, FooterColor::Green), + + (Matching(false), true, true) => (Some(Text(false)), FooterColor::Red), + (Text(false), true, true) => (Some(Text(true)), FooterColor::Yellow), + (Text(true), true, true) => (None, FooterColor::Green), + + (s, m, t) => unreachable!("Bad progression: {s:?} with matching = {m} and text = {t}"), + } + } + + #[rustfmt::skip] + fn regress(self, recall_settings: &RecallSettings) -> Option<(StudyType, FooterColor)> { + use StudyType::*; + match (self, recall_settings.matching, recall_settings.text) { + (Matching(true), true, false) => Some((Matching(false), FooterColor::Black)), + + (Text(true), false, true) => Some((Text(false), FooterColor::Black)), + + (Text(false), true, true) => Some((Matching(false), FooterColor::Black)), + (Text(true), true, true) => Some((Text(false), FooterColor::Red)), + + _ => None, + } + } +} + +pub mod fail_count { + use std::{fmt, ops::Add}; + + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] + pub struct FailCount(u8); + + impl fmt::Display for FailCount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.0 > 100 { + write!(f, "100+") + } else { + write!(f, "{}", self.0) + } + } + } + + impl fmt::Debug for FailCount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.0 > 100 { + write!(f, "100+") + } else { + write!(f, "{}", self.0) + } + } + } + + impl FailCount { + pub const ZERO: Self = Self(0); + + pub fn inc(&mut self) { + self.0 = self.0.saturating_add(1); + } + + pub fn has_failed(&self) -> bool { + self.0 > 0 + } + + pub fn count(&self) -> u8 { + self.0 + } + } + + impl Add for FailCount { + type Output = FailCount; + + fn add(self, rhs: FailCount) -> Self::Output { + FailCount(self.0.saturating_add(rhs.0)) + } + } +} diff --git a/src/study/learn/world/footer.rs b/src/study/learn/world/footer.rs new file mode 100644 index 0000000..7304ec9 --- /dev/null +++ b/src/study/learn/world/footer.rs @@ -0,0 +1,109 @@ +use std::io; + +use crossterm::{ + cursor, queue, + style::{self, Color}, +}; + +use crate::{output::Repeat, vec2::Vec2}; + +#[derive(Debug)] +pub struct Footer { + vals: [u32; 4], + width: u16, + y: u16, +} + +impl Footer { + /// Constructs this with `count` black items and renders this + pub fn new(count: u32, term_size: Vec2) -> Self { + let this = Footer { + vals: [count, 0, 0, 0], + width: term_size.x, + y: term_size.y - 1, + }; + this.render(); + this + } + + /// Moves an item from `curr` to `dst` and renders this + pub fn r#move(&mut self, curr: FooterColor, dst: FooterColor) { + self.vals[dst as usize] += 1; + self.vals[curr as usize] -= 1; + self.render(); + } + + pub fn resize(&mut self, term_size: Vec2) { + self.width = term_size.x; + self.y = term_size.y - 1; + self.render(); + } + + pub fn render(&self) { + queue!( + io::stdout(), + cursor::MoveTo(0, self.y), + style::SetForegroundColor(Color::White), + ) + .unwrap(); + + let count = self.vals.into_iter().sum::() as f64; + let width = self.width as f64; + + fn print_section(amount: u32, width: u16, color: Color) -> u16 { + let amount = format!("{amount}"); + let amount = &amount[..(width as usize).min(amount.len())]; + let pad = width - amount.len() as u16; + let left_pad = pad / 2; + let right_pad = pad - left_pad; + queue!( + io::stdout(), + style::SetBackgroundColor(color), + style::Print(Repeat(' ', left_pad)), + style::Print(amount), + style::Print(Repeat(' ', right_pad)), + ) + .unwrap(); + width + } + + let mut leftover_width = self.width; + let width = |val: u32| (((val as f64) / count) * width) as u16; + + leftover_width -= print_section( + self.vals[FooterColor::Green as usize], + width(self.vals[FooterColor::Green as usize]), + Color::DarkGreen, + ); + leftover_width -= print_section( + self.vals[FooterColor::Yellow as usize], + width(self.vals[FooterColor::Yellow as usize]), + Color::DarkYellow, + ); + leftover_width -= print_section( + self.vals[FooterColor::Red as usize], + width(self.vals[FooterColor::Red as usize]), + Color::DarkRed, + ); + leftover_width -= print_section( + self.vals[FooterColor::Black as usize], + width(self.vals[FooterColor::Black as usize]), + Color::Black, + ); + + queue!( + io::stdout(), + style::Print(Repeat(' ', leftover_width)), + style::SetBackgroundColor(Color::Reset) + ) + .unwrap(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FooterColor { + Black = 0, + Red = 1, + Yellow = 2, + Green = 3, +} diff --git a/src/vec2.rs b/src/vec2.rs index e92a9a8..4c7124c 100644 --- a/src/vec2.rs +++ b/src/vec2.rs @@ -1,6 +1,6 @@ use std::{ array, - ops::{Add, AddAssign, Div, Mul, Sub}, + ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Sub, SubAssign}, }; use crossterm::cursor::MoveTo; @@ -70,13 +70,27 @@ impl> Vec2 { } } +#[allow(dead_code)] impl Vec2 { pub const ZERO: Vec2 = Vec2::new(0, 0); + pub const ONE: Vec2 = Vec2::new(1, 1); + pub const X: Vec2 = Vec2::new(1, 0); + pub const Y: Vec2 = Vec2::new(0, 1); #[must_use] pub fn move_to(self) -> MoveTo { MoveTo(self.x, self.y) } + + #[must_use] + pub fn x(x: u16) -> Self { + Self { x, y: 0 } + } + + #[must_use] + pub fn y(y: u16) -> Self { + Self { x: 0, y } + } } impl IntoIterator for Vec2 { @@ -126,62 +140,45 @@ impl, U: Copy, O: Copy> Div> for Vec2 { } } -pub trait DefaultStep { - fn default_step() -> Self; +impl, U: Copy> AddAssign> for Vec2 { + fn add_assign(&mut self, rhs: Vec2) { + self.x += rhs.x; + self.y += rhs.y; + } +} + +impl, U: Copy> SubAssign> for Vec2 { + fn sub_assign(&mut self, rhs: Vec2) { + self.x -= rhs.x; + self.y -= rhs.y; + } } -impl DefaultStep for u16 { - fn default_step() -> Self { - 1 +impl, U: Copy> MulAssign> for Vec2 { + fn mul_assign(&mut self, rhs: Vec2) { + self.x *= rhs.x; + self.y *= rhs.y; } } -impl + PartialOrd + DefaultStep> Vec2 { - /// Returns an iterator over the positions between `self` and `other`. - /// Inclusive of `self`, exclusive of `other` - /// - /// # Panics - /// - /// Panics if `self.x` > `other.x` or `self.y` > `other.y` - pub fn positions_between(self, other: Vec2) -> PositionsIter { - PositionsIter { - start_x: self.x, - next: Some(self).filter(|_| self.x < other.x && self.y < other.y), - end: other, - step: T::default_step(), - } +impl, U: Copy> DivAssign> for Vec2 { + fn div_assign(&mut self, rhs: Vec2) { + self.x /= rhs.x; + self.y /= rhs.y; } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PositionsIter + PartialOrd> { - start_x: T, - next: Option>, - end: Vec2, - step: T, -} - -impl + PartialOrd> Iterator for PositionsIter { - type Item = Vec2; - - fn next(&mut self) -> Option { - let ret = self.next; - self.next = if let Some(mut last) = self.next { - last.x += self.step; - if last.x < self.end.x { - Some(last) - } else { - last.y += self.step; - if last.y < self.end.y { - last.x = self.start_x; - Some(last) - } else { - None - } - } - } else { - None - }; - ret +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct Rect { + pub pos: Vec2, + pub size: Vec2, +} + +impl Rect { + pub fn shrink_centered(mut self, factor: Vec2) -> Rect { + self.pos += factor; + self.size -= factor; + self.size -= factor; + self } }