From ab085a0c75f4d6df75032b3acc73eb7812d47cbe Mon Sep 17 00:00:00 2001 From: NonbinaryCoder Date: Sun, 16 Oct 2022 07:19:54 -0400 Subject: [PATCH 1/8] Refactored flascard parsing and study to use Rc --- src/flashcards.rs | 64 ++-- src/output.rs | 593 +++-------------------------------- src/output/text_box.rs | 436 +++++++++++++++++++++++++ src/study/flashcards.rs | 293 +++++++++++++---- src/study/flashcards/grid.rs | 195 ------------ src/vec2.rs | 97 +++--- 6 files changed, 799 insertions(+), 879 deletions(-) create mode 100644 src/output/text_box.rs delete mode 100644 src/study/flashcards/grid.rs diff --git a/src/flashcards.rs b/src/flashcards.rs index 721de98..71262c5 100644 --- a/src/flashcards.rs +++ b/src/flashcards.rs @@ -3,6 +3,7 @@ use std::{ fs, ops::{Index, IndexMut, Not}, path::Path, + rc::Rc, str::FromStr, }; @@ -97,9 +98,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(), @@ -330,32 +335,61 @@ 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(&mut self, val: String) { - self.0.push(val); + pub fn push_display(&mut self, val: impl Into>) { + self.vals.push(val.into()); + self.num_display = self + .num_display + .checked_add(1) + .expect("Flaschards cannot have more than 255 values!"); } - pub fn display(&self) -> &str { - self.0.choose(&mut rand::thread_rng()).unwrap() + pub fn push_accepted(&mut self, val: impl Into>) { + self.vals.push(val.into()); + } + + pub fn display(&self) -> &Rc { + self.display_options() + .choose(&mut rand::thread_rng()) + .unwrap() } } 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,13 +401,7 @@ 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) } } diff --git a/src/output.rs b/src/output.rs index 63cd9ae..70e4146 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,13 +1,8 @@ -use std::{borrow::Cow, fmt::Display, io}; +use std::{fmt::Display, io}; -use crossterm::{ - cursor, execute, queue, - style::{self, Attribute, Attributes, Color, Stylize}, - terminal, -}; - -use crate::{output::word_wrap::WordWrap, vec2::Vec2}; +use crossterm::{cursor, execute, queue, style::Stylize, terminal}; +pub mod text_box; pub mod word_wrap; pub fn write_fatal_error(text: &str) { @@ -31,6 +26,7 @@ pub struct TerminalSettings { alternate_screen: bool, cursor_hidden: bool, raw_mode: bool, + exiting_properly: bool, } #[allow(dead_code)] @@ -74,10 +70,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); } @@ -90,551 +93,6 @@ impl Drop for TerminalSettings { } } -#[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, -} - -#[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 - } - - /// 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), - } - self - } - - fn get_lines_iter<'a>(&self, text: &'a str) -> impl Iterator> { - let inner_size = self.inner_size(); - - 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() - } - } - } - } - } - - 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), - }, - ) - } - } - } - - 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 - }; - - queue!( - io::stdout(), - corner_pos.move_to(), - style::SetForegroundColor(self.content_color), - style::SetAttributes(self.attributes) - ) - .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(); - } - } - - 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 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(); - } - } - } - - /// 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), - } - 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 { - queue!( - io::stdout(), - 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 - } - }; - - 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(); - } - } 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 - } -} - -#[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 { @@ -643,6 +101,16 @@ pub enum TextAlignH { Right, } +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, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] pub enum TextAlignV { @@ -650,3 +118,22 @@ pub enum TextAlignV { Center, Bottom, } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextAlign { + h: TextAlignH, + v: TextAlignV, +} + +impl TextAlign { + pub fn center() -> Self { + Self { + h: TextAlignH::Center, + v: TextAlignV::Center, + } + } + + pub fn new(h: TextAlignH, v: TextAlignV) -> TextAlign { + Self { h, v } + } +} diff --git a/src/output/text_box.rs b/src/output/text_box.rs new file mode 100644 index 0000000..50031d3 --- /dev/null +++ b/src/output/text_box.rs @@ -0,0 +1,436 @@ +use std::{borrow::Cow, io}; + +use crossterm::{ + cursor, queue, + style::{self, Color}, +}; + +use crate::{ + output::{word_wrap::WordWrap, Repeat}, + vec2::{Rect, Vec2}, +}; + +use super::{TextAlign, TextAlignH, TextAlignV}; + +#[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, + new_text_align: self.text_align, + redraw_text: false, + redraw_outline: false, + inner: self, + }; + f(&mut updater); + let TextBoxUpdater { + inner: _, + new_text, + new_text_align, + redraw_text, + redraw_outline, + } = updater; + + if redraw_outline { + 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) => unreachable!(), + (None, Some(new_text)) => { + draw_text( + self.inner_size(), + self.text_color, + new_text.as_ref(), + self.text_align, + ); + self.text = Some(new_text); + } + (Some(old_text), None) => { + overwrite_text( + self.inner_size(), + self.text_color, + old_text.as_ref(), + self.text_align, + "", + TextAlign::new(TextAlignH::Left, TextAlignV::Top), + ); + self.text = None; + } + (Some(old_text), Some(new_text)) => { + overwrite_text( + self.inner_size(), + self.text_color, + old_text.as_ref(), + self.text_align, + new_text.as_ref(), + new_text_align, + ); + self.text = Some(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 { + draw_outline(self.dims, self.outline_color, outline_type); + } + if let Some(text) = &self.text { + draw_text( + self.inner_size(), + self.text_color, + text.as_ref(), + self.text_align, + ) + } + } + + fn inner_size(&self) -> Rect { + self.dims.shrink_centered(Vec2::splat(1)) + } + + pub fn get_text(&self) -> &Option { + &self.text + } +} + +fn draw_outline(dims: Rect, color: Color, r#type: OutlineType) { + queue!( + io::stdout(), + dims.pos.move_to(), + style::SetForegroundColor(color), + style::Print(r#type.tl), + style::Print(Repeat(r#type.h, dims.size.x - 2)), + style::Print(r#type.tr) + ) + .unwrap(); + for _ in 0..(dims.size.y - 2) { + queue!( + io::stdout(), + cursor::MoveDown(1), + cursor::MoveToColumn(dims.pos.x), + style::Print(r#type.v), + cursor::MoveRight(dims.size.x - 2), + style::Print(r#type.v), + ) + .unwrap(); + } + queue!( + io::stdout(), + cursor::MoveDown(1), + cursor::MoveToColumn(dims.pos.x), + style::Print(r#type.bl), + style::Print(Repeat(r#type.h, dims.size.x - 2)), + style::Print(r#type.br) + ) + .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), + } + 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() + } + } + } + } + } + + 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), + }, + ) + } + } +} + +fn draw_text(dims: Rect, color: Color, text: &str, align: TextAlign) { + let lines = get_lines_iter(dims.size, text, align.v); + queue!( + io::stdout(), + dims.pos.move_to(), + 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), + ) + .unwrap(); + } + } +} + +fn overwrite_text( + dims: Rect, + color: Color, + old_text: &str, + old_align: TextAlign, + new_text: &str, + new_align: TextAlign, +) { + 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(), + dims.pos.move_to(), + 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), + ) + .unwrap(), + (Some(old_line), None) => queue!( + io::stdout(), + cursor::MoveTo( + dims.pos.x + old_align.h.padding_for(old_line, dims.size.x), + y + ), + style::Print(Repeat(' ', old_line.chars().count() as u16)), + ) + .unwrap(), + (Some(old_line), Some(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_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(); + } + } + } + } +} + +#[derive(Debug)] +pub struct TextBoxUpdater<'a, S: AsRef> { + inner: &'a mut TextBox, + new_text: Option, + 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(text); + self + } + + pub fn clear_text(&mut self) -> &mut Self { + self.new_text = None; + self.redraw_text |= self.inner.text.is_some(); + self + } + + pub fn set_text_color(&mut self, color: Color) -> &mut Self { + self.redraw_text |= !set_and_compare(&mut self.inner.text_color, color); + 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) + } + + pub fn clear_all(&mut self) -> &mut Self { + self.clear_outline().clear_text() + } +} + +/// 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 { + tl: char, + tr: char, + bl: char, + br: char, + h: char, + 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: ' ', + }; +} diff --git a/src/study/flashcards.rs b/src/study/flashcards.rs index 2a84640..857f129 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,243 @@ 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)] + .update(|updater| { + updater.clear_all(); + }) + } + } } } - }), - 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/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 } } From 1e978622fb645635d2a83bccfac5f5fdff9de0ba Mon Sep 17 00:00:00 2001 From: NonbinaryCoder Date: Sat, 22 Oct 2022 08:01:39 -0400 Subject: [PATCH 2/8] Implemented rendering of matching in learn mode --- src/flashcards.rs | 11 +- src/main.rs | 2 + src/output.rs | 7 +- src/output/text_box.rs | 351 ++++++++++++++++++++++++++++++++++++++--- src/study.rs | 1 + src/study/learn.rs | 247 +++++++++++++++++++++++++++++ 6 files changed, 593 insertions(+), 26 deletions(-) create mode 100644 src/study/learn.rs diff --git a/src/flashcards.rs b/src/flashcards.rs index 71262c5..7c49e5c 100644 --- a/src/flashcards.rs +++ b/src/flashcards.rs @@ -288,8 +288,15 @@ macro_rules! load_set { #[derive(Debug, Default, Clone)] pub struct RecallSettings { - matching: bool, - text: bool, + pub matching: bool, + pub text: bool, +} + +impl RecallSettings { + /// Returns true if this uses matching or text + pub fn is_used(&self) -> bool { + self.matching || self.text + } } #[derive(Debug, Clone)] 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 70e4146..550814e 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,6 +1,10 @@ use std::{fmt::Display, io}; -use crossterm::{cursor, execute, queue, style::Stylize, terminal}; +use crossterm::{ + cursor, execute, queue, + style::{self, Color, Stylize}, + terminal, +}; pub mod text_box; pub mod word_wrap; @@ -90,6 +94,7 @@ impl Drop for TerminalSettings { if self.raw_mode { let _ = terminal::disable_raw_mode(); } + let _ = execute!(io::stdout(), style::SetForegroundColor(Color::Reset)); } } diff --git a/src/output/text_box.rs b/src/output/text_box.rs index 50031d3..2ce28f5 100644 --- a/src/output/text_box.rs +++ b/src/output/text_box.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, io}; +use std::{array, borrow::Cow, io}; use crossterm::{ cursor, queue, @@ -73,7 +73,7 @@ impl> TextBox { if redraw_text { match (self.text.as_ref(), new_text) { - (None, None) => unreachable!(), + (None, None) => {} (None, Some(new_text)) => { draw_text( self.inner_size(), @@ -135,14 +135,14 @@ impl> TextBox { } } -fn draw_outline(dims: Rect, color: Color, r#type: OutlineType) { +fn draw_outline(dims: Rect, color: Color, typ: OutlineType) { queue!( io::stdout(), dims.pos.move_to(), style::SetForegroundColor(color), - style::Print(r#type.tl), - style::Print(Repeat(r#type.h, dims.size.x - 2)), - style::Print(r#type.tr) + 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) { @@ -150,9 +150,9 @@ fn draw_outline(dims: Rect, color: Color, r#type: OutlineType) { io::stdout(), cursor::MoveDown(1), cursor::MoveToColumn(dims.pos.x), - style::Print(r#type.v), + style::Print(typ.v), cursor::MoveRight(dims.size.x - 2), - style::Print(r#type.v), + style::Print(typ.v), ) .unwrap(); } @@ -160,9 +160,9 @@ fn draw_outline(dims: Rect, color: Color, r#type: OutlineType) { io::stdout(), cursor::MoveDown(1), cursor::MoveToColumn(dims.pos.x), - style::Print(r#type.bl), - style::Print(Repeat(r#type.h, dims.size.x - 2)), - style::Print(r#type.br) + style::Print(typ.bl), + style::Print(Repeat(typ.h, dims.size.x - 2)), + style::Print(typ.br) ) .unwrap(); } @@ -230,12 +230,7 @@ fn get_lines_iter( fn draw_text(dims: Rect, color: Color, text: &str, align: TextAlign) { let lines = get_lines_iter(dims.size, text, align.v); - queue!( - io::stdout(), - dims.pos.move_to(), - style::SetForegroundColor(color) - ) - .unwrap(); + queue!(io::stdout(), style::SetForegroundColor(color)).unwrap(); for (index, line) in lines.enumerate() { let line = &line; if !line.is_empty() { @@ -262,12 +257,7 @@ fn overwrite_text( ) { 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(), - dims.pos.move_to(), - style::SetForegroundColor(color) - ) - .unwrap(); + 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()), @@ -434,3 +424,318 @@ impl OutlineType { 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, + 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, + 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(new_text)) => { + draw_text(dims, item.color, new_text.as_ref(), item.align); + item.text = Some(new_text); + } + (Some(old_text), None) => { + overwrite_text( + dims, + item.color, + old_text.as_ref(), + item.align, + "", + TextAlign::new(TextAlignH::Left, TextAlignV::Top), + ); + item.text = None; + } + (Some(old_text), Some(new_text)) => { + overwrite_text( + dims, + item.color, + old_text.as_ref(), + item.align, + new_text.as_ref(), + new_align, + ); + item.text = Some(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 { + 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; + } + } +} + +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, + 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 text(&mut self, index: usize) -> MultiTextBoxItemUpdater { + MultiTextBoxItemUpdater { + item: &mut self.inner.items[index], + changes: &mut self.items[index], + } + } +} + +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(text); + 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/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/learn.rs b/src/study/learn.rs new file mode 100644 index 0000000..3871f94 --- /dev/null +++ b/src/study/learn.rs @@ -0,0 +1,247 @@ +use std::{ + array, + io::{self, Write}, + path::PathBuf, +}; + +use argh::FromArgs; +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + queue, + terminal::{self, ClearType}, +}; +use rand::{seq::SliceRandom, Rng}; + +use crate::{ + flashcards::{Flashcard, RecallSettings, Set, Side}, + load_set, + output::{ + self, + text_box::{MultiOutlineType, MultiTextBox, OutlineType, TextBox}, + TerminalSettings, + }, + vec2::{Rect, Vec2}, +}; + +/// 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 cards = CardList::from_set(&set); + + let mut term_settings = TerminalSettings::new(); + term_settings + .enable_raw_mode() + .enter_alternate_screen() + .hide_cursor(); + + let mut question_box; + let mut matching_answers_boxes: MultiTextBox<_, 4>; + { + let term_size: Vec2<_> = terminal::size() + .expect("unable to get terminal size") + .into(); + let height_minus_padding = term_size.y.saturating_sub(7); + let box_height = height_minus_padding / 2; + + question_box = TextBox::from_fn( + Rect { + size: Vec2::new(term_size.x / 3, box_height), + pos: Vec2::new(term_size.x / 3, 2), + }, + |updater| { + updater.set_outline(OutlineType::DOUBLE); + }, + ); + + matching_answers_boxes = MultiTextBox::new(Rect { + size: Vec2::new(term_size.x.saturating_sub(8), box_height), + pos: Vec2::new(4, term_size.y.saturating_sub(box_height).saturating_sub(3)), + }) + } + + while cards.select_next() { + let &CardListItem { + card, + side, + next_study_type, + } = cards.selected(); + match next_study_type { + StudyType::Matching(studied_before) => { + question_box.update(|updater| { + updater.set_text(card[side].display().clone()); + }); + matching_answers_boxes.update(|updater| { + updater.set_outline(MultiOutlineType::DOUBLE_LIGHT); + + let mut answers: [_; 4] = array::from_fn(|_| None); + answers[0] = Some(card[!side].display().clone()); + for i in 1..4 { + for _ in 0..12 { + answers[i] = Some( + set.cards.choose(&mut rand::thread_rng()).unwrap()[!side] + .display() + .clone(), + ); + if !answers[..i].contains(&answers[i]) { + break; + } + } + } + let mut answers = answers.map(Option::unwrap); + answers.shuffle(&mut rand::thread_rng()); + + for (index, text) in answers.into_iter().enumerate() { + updater.text(index).set(text); + } + }); + io::stdout().flush().unwrap(); + loop { + match event::read().unwrap() { + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers, + .. + }) if modifiers.contains(KeyModifiers::CONTROL) => { + term_settings.clear(); + panic!("Exited with ctrl-c"); + } + Event::Resize(x, y) => { + let term_size: Vec2<_> = terminal::size() + .expect("unable to get terminal size") + .into(); + let height_minus_padding = term_size.y.saturating_sub(7); + let box_height = height_minus_padding / 2; + + queue!(io::stdout(), terminal::Clear(ClearType::All)).unwrap(); + + question_box.force_move_resize(Rect { + size: Vec2::new(term_size.x / 3, box_height), + pos: Vec2::new(term_size.x / 3, 2), + }); + + matching_answers_boxes.force_move_resize(Rect { + size: Vec2::new(term_size.x.saturating_sub(8), box_height), + pos: Vec2::new( + 4, + term_size.y.saturating_sub(box_height).saturating_sub(3), + ), + }); + + io::stdout().flush().unwrap(); + } + _ => break, + } + } + } + _ => todo!(), + } + } + + term_settings.clear(); + } +} + +struct CardListItem<'a> { + card: &'a Flashcard, + side: Side, + next_study_type: StudyType, +} + +struct CardList<'a> { + cards: Vec>, + set: &'a Set, + selected_index: usize, +} + +impl<'a> CardList<'a> { + fn from_set(set: &'a Set) -> Self { + let count = [set.recall_t.is_used(), set.recall_d.is_used()] + .into_iter() + .filter(|b| *b) + .count(); + let mut cards = Vec::with_capacity(count * set.cards.len()); + + let mut extend_cards = |recall_settings: &RecallSettings, side| { + let next_study_type = match *recall_settings { + RecallSettings { + matching: true, + text: true, + } => StudyType::Matching(true), + RecallSettings { + matching: true, + text: false, + } => StudyType::Matching(false), + RecallSettings { + matching: false, + text: true, + } => StudyType::Text(false), + RecallSettings { + matching: false, + text: false, + } => return, + }; + cards.extend(set.cards.iter().map(|card| CardListItem { + card, + side, + next_study_type, + })) + }; + + extend_cards(&set.recall_t, Side::Term); + extend_cards(&set.recall_d, Side::Definition); + + Self { + cards, + set, + selected_index: usize::MAX, + } + } + + fn select_next(&mut self) -> bool { + if self.cards.is_empty() { + false + } else { + let mut index = self.selected_index; + let mut counter = 0; + while index == self.selected_index && counter < 12 { + index = rand::thread_rng().gen_range(0..self.cards.len()); + } + self.selected_index = index; + true + } + } + + fn selected(&self) -> &CardListItem { + &self.cards[self.selected_index] + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +enum StudyType { + Matching(bool), + Text(bool), +} + +impl StudyType { + fn index(self) -> usize { + match self { + StudyType::Matching(false) => 0, + StudyType::Matching(true) => 1, + StudyType::Text(false) => 2, + StudyType::Text(true) => 3, + } + } +} From 29da323dc09d8b5e57a23b7ba5fa1661d84330de Mon Sep 17 00:00:00 2001 From: NonbinaryCoder Date: Mon, 31 Oct 2022 19:53:29 -0400 Subject: [PATCH 3/8] Incomplete implementation of matching in learn mode --- src/flashcards.rs | 43 +++++++++++++++++++++-- src/output/text_box.rs | 23 ++++++------- src/study/learn.rs | 78 +++++++++++++++++++++++++++++------------- 3 files changed, 105 insertions(+), 39 deletions(-) diff --git a/src/flashcards.rs b/src/flashcards.rs index 7c49e5c..9f5b098 100644 --- a/src/flashcards.rs +++ b/src/flashcards.rs @@ -1,10 +1,11 @@ use std::{ + char::ToLowercase, fmt::{Display, Write}, fs, ops::{Index, IndexMut, Not}, path::Path, rc::Rc, - str::FromStr, + str::{Chars, FromStr}, }; use crossterm::style::Color; @@ -42,6 +43,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 { @@ -371,7 +379,7 @@ impl FlashcardText { self.num_display = self .num_display .checked_add(1) - .expect("Flaschards cannot have more than 255 values!"); + .expect("Flascards cannot have more than 255 values!"); } pub fn push_accepted(&mut self, val: impl Into>) { @@ -383,6 +391,37 @@ impl FlashcardText { .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 { diff --git a/src/output/text_box.rs b/src/output/text_box.rs index 2ce28f5..71fac38 100644 --- a/src/output/text_box.rs +++ b/src/output/text_box.rs @@ -527,20 +527,12 @@ impl, const ITEMS: usize> MultiTextBox { }; match (item.text.as_ref(), new_text) { (None, None) => {} - (None, Some(new_text)) => { - draw_text(dims, item.color, new_text.as_ref(), item.align); - item.text = Some(new_text); + (None, Some(text)) => { + draw_text(dims, item.color, text.as_ref(), item.align); + item.text = Some(text); } - (Some(old_text), None) => { - overwrite_text( - dims, - item.color, - old_text.as_ref(), - item.align, - "", - TextAlign::new(TextAlignH::Left, TextAlignV::Top), - ); - item.text = None; + (Some(text), None) => { + draw_text(dims, item.color, text.as_ref(), item.align); } (Some(old_text), Some(new_text)) => { overwrite_text( @@ -696,6 +688,11 @@ impl<'a, S: AsRef> MultiTextBoxItemUpdater<'a, S> { self.changes.new_text = Some(text); self } + + pub fn set_color(&mut self, color: Color) -> &mut Self { + self.changes.redraw |= !set_and_compare(&mut self.item.color, color); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/study/learn.rs b/src/study/learn.rs index 3871f94..da831e6 100644 --- a/src/study/learn.rs +++ b/src/study/learn.rs @@ -8,6 +8,7 @@ use argh::FromArgs; use crossterm::{ event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, queue, + style::Color, terminal::{self, ClearType}, }; use rand::{seq::SliceRandom, Rng}; @@ -72,7 +73,7 @@ impl Entry { }) } - while cards.select_next() { + 'study_loop: while cards.select_next() { let &CardListItem { card, side, @@ -80,31 +81,30 @@ impl Entry { } = cards.selected(); match next_study_type { StudyType::Matching(studied_before) => { + let mut answers: [_; 4] = array::from_fn(|_| None); + answers[0] = Some(card[!side].display().clone()); + for i in 1..4 { + for _ in 0..12 { + answers[i] = Some( + set.cards.choose(&mut rand::thread_rng()).unwrap()[!side] + .display() + .clone(), + ); + if !answers[..i].contains(&answers[i]) { + break; + } + } + } + let mut answers = answers.map(Option::unwrap); + answers.shuffle(&mut rand::thread_rng()); + question_box.update(|updater| { updater.set_text(card[side].display().clone()); }); matching_answers_boxes.update(|updater| { updater.set_outline(MultiOutlineType::DOUBLE_LIGHT); - - let mut answers: [_; 4] = array::from_fn(|_| None); - answers[0] = Some(card[!side].display().clone()); - for i in 1..4 { - for _ in 0..12 { - answers[i] = Some( - set.cards.choose(&mut rand::thread_rng()).unwrap()[!side] - .display() - .clone(), - ); - if !answers[..i].contains(&answers[i]) { - break; - } - } - } - let mut answers = answers.map(Option::unwrap); - answers.shuffle(&mut rand::thread_rng()); - - for (index, text) in answers.into_iter().enumerate() { - updater.text(index).set(text); + for (index, text) in answers.iter().cloned().enumerate() { + updater.text(index).set(text).set_color(Color::White); } }); io::stdout().flush().unwrap(); @@ -119,9 +119,7 @@ impl Entry { panic!("Exited with ctrl-c"); } Event::Resize(x, y) => { - let term_size: Vec2<_> = terminal::size() - .expect("unable to get terminal size") - .into(); + let term_size = Vec2::new(x, y); let height_minus_padding = term_size.y.saturating_sub(7); let box_height = height_minus_padding / 2; @@ -142,6 +140,38 @@ impl Entry { io::stdout().flush().unwrap(); } + Event::Key(KeyEvent { + code: KeyCode::Char(c), + .. + }) if ('1'..='4').contains(&c) => { + let index = match c { + '1' => 0, + '2' => 1, + '3' => 2, + '4' => 3, + _ => unreachable!(), + }; + + if card[!side] + .contains(&answers[index], cards.set.recall_settings(side)) + { + matching_answers_boxes.update(|updater| { + updater.text(index).set_color(Color::Green); + }); + io::stdout().flush().unwrap(); + loop { + if let Event::Key(_) = event::read().unwrap() { + break; + } + } + continue 'study_loop; + } else { + matching_answers_boxes.update(|updater| { + updater.text(index).set_color(Color::Red); + }); + io::stdout().flush().unwrap(); + } + } _ => break, } } From 18a83b5bdb54046086ddaed1730e81381e7b2c33 Mon Sep 17 00:00:00 2001 From: NonbinaryCoder Date: Thu, 3 Nov 2022 17:33:48 -0400 Subject: [PATCH 4/8] Implemented matching in learn mode --- src/flashcards.rs | 9 +- src/output/text_box.rs | 35 ++-- src/study/learn.rs | 269 ++++------------------------- src/study/learn/world.rs | 185 ++++++++++++++++++++ src/study/learn/world/card_list.rs | 228 ++++++++++++++++++++++++ src/study/learn/world/footer.rs | 92 ++++++++++ 6 files changed, 562 insertions(+), 256 deletions(-) create mode 100644 src/study/learn/world.rs create mode 100644 src/study/learn/world/card_list.rs create mode 100644 src/study/learn/world/footer.rs diff --git a/src/flashcards.rs b/src/flashcards.rs index 9f5b098..2b4cc34 100644 --- a/src/flashcards.rs +++ b/src/flashcards.rs @@ -1,5 +1,4 @@ use std::{ - char::ToLowercase, fmt::{Display, Write}, fs, ops::{Index, IndexMut, Not}, @@ -294,19 +293,13 @@ macro_rules! load_set { }; } +// NOT `Copy` because non-Copy fields may be added in the future #[derive(Debug, Default, Clone)] pub struct RecallSettings { pub matching: bool, pub text: bool, } -impl RecallSettings { - /// Returns true if this uses matching or text - pub fn is_used(&self) -> bool { - self.matching || self.text - } -} - #[derive(Debug, Clone)] pub struct Flashcard { pub term: FlashcardText, diff --git a/src/output/text_box.rs b/src/output/text_box.rs index 71fac38..c231d3f 100644 --- a/src/output/text_box.rs +++ b/src/output/text_box.rs @@ -78,10 +78,10 @@ impl> TextBox { draw_text( self.inner_size(), self.text_color, - new_text.as_ref(), + new_text.as_ref().map(AsRef::as_ref).unwrap_or_default(), self.text_align, ); - self.text = Some(new_text); + self.text = new_text; } (Some(old_text), None) => { overwrite_text( @@ -100,10 +100,10 @@ impl> TextBox { self.text_color, old_text.as_ref(), self.text_align, - new_text.as_ref(), + new_text.as_ref().map(AsRef::as_ref).unwrap_or_default(), new_text_align, ); - self.text = Some(new_text); + self.text = new_text; } } self.text_align = new_text_align; @@ -310,7 +310,7 @@ fn overwrite_text( #[derive(Debug)] pub struct TextBoxUpdater<'a, S: AsRef> { inner: &'a mut TextBox, - new_text: Option, + new_text: Option>, new_text_align: TextAlign, redraw_text: bool, redraw_outline: bool, @@ -322,12 +322,12 @@ impl<'a, S: AsRef> TextBoxUpdater<'a, S> { Some(old_text) => self.redraw_text |= old_text.as_ref() != text.as_ref(), None => self.redraw_text = true, } - self.new_text = Some(text); + self.new_text = Some(Some(text)); self } pub fn clear_text(&mut self) -> &mut Self { - self.new_text = None; + self.new_text = Some(None); self.redraw_text |= self.inner.text.is_some(); self } @@ -528,8 +528,13 @@ impl, const ITEMS: usize> MultiTextBox { match (item.text.as_ref(), new_text) { (None, None) => {} (None, Some(text)) => { - draw_text(dims, item.color, text.as_ref(), item.align); - item.text = Some(text); + draw_text( + dims, + item.color, + text.as_ref().map(AsRef::as_ref).unwrap_or_default(), + item.align, + ); + item.text = text; } (Some(text), None) => { draw_text(dims, item.color, text.as_ref(), item.align); @@ -540,10 +545,10 @@ impl, const ITEMS: usize> MultiTextBox { item.color, old_text.as_ref(), item.align, - new_text.as_ref(), + new_text.as_ref().map(AsRef::as_ref).unwrap_or_default(), new_align, ); - item.text = Some(new_text); + item.text = new_text; } } item.align = new_align; @@ -581,6 +586,10 @@ impl, const ITEMS: usize> MultiTextBox { selected_pos.x += self.item_size.x + 1; } } + + pub fn text(&self, index: usize) -> Option<&S> { + self.items[index].text.as_ref() + } } fn draw_multi_outline( @@ -654,7 +663,7 @@ pub struct MultiTextBoxUpdater<'a, S: AsRef, const ITEMS: usize> { #[derive(Debug)] pub struct MultiTextBoxItemChanges> { - new_text: Option, + new_text: Option>, new_align: TextAlign, redraw: bool, } @@ -685,7 +694,7 @@ impl<'a, S: AsRef> MultiTextBoxItemUpdater<'a, S> { Some(old_text) => self.changes.redraw |= old_text.as_ref() != text.as_ref(), None => self.changes.redraw = true, } - self.changes.new_text = Some(text); + self.changes.new_text = Some(Some(text)); self } diff --git a/src/study/learn.rs b/src/study/learn.rs index da831e6..ca78432 100644 --- a/src/study/learn.rs +++ b/src/study/learn.rs @@ -1,29 +1,22 @@ -use std::{ - array, - io::{self, Write}, - path::PathBuf, -}; +use std::path::PathBuf; use argh::FromArgs; use crossterm::{ event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, - queue, - style::Color, - terminal::{self, ClearType}, + terminal, }; -use rand::{seq::SliceRandom, Rng}; use crate::{ - flashcards::{Flashcard, RecallSettings, Set, Side}, + flashcards::Set, load_set, - output::{ - self, - text_box::{MultiOutlineType, MultiTextBox, OutlineType, TextBox}, - TerminalSettings, - }, - vec2::{Rect, Vec2}, + output::{self, TerminalSettings}, + vec2::Vec2, }; +use self::world::World; + +mod world; + /// Learn a set #[derive(FromArgs, Debug)] #[argh(subcommand, name = "learn")] @@ -40,7 +33,6 @@ impl Entry { output::write_fatal_error("Set must have at least 1 card to learn"); return; } - let mut cards = CardList::from_set(&set); let mut term_settings = TerminalSettings::new(); term_settings @@ -48,230 +40,37 @@ impl Entry { .enter_alternate_screen() .hide_cursor(); - let mut question_box; - let mut matching_answers_boxes: MultiTextBox<_, 4>; - { - let term_size: Vec2<_> = terminal::size() + let mut world = World::new( + terminal::size() .expect("unable to get terminal size") - .into(); - let height_minus_padding = term_size.y.saturating_sub(7); - let box_height = height_minus_padding / 2; - - question_box = TextBox::from_fn( - Rect { - size: Vec2::new(term_size.x / 3, box_height), - pos: Vec2::new(term_size.x / 3, 2), - }, - |updater| { - updater.set_outline(OutlineType::DOUBLE); - }, - ); - - matching_answers_boxes = MultiTextBox::new(Rect { - size: Vec2::new(term_size.x.saturating_sub(8), box_height), - pos: Vec2::new(4, term_size.y.saturating_sub(box_height).saturating_sub(3)), - }) - } - - 'study_loop: while cards.select_next() { - let &CardListItem { - card, - side, - next_study_type, - } = cards.selected(); - match next_study_type { - StudyType::Matching(studied_before) => { - let mut answers: [_; 4] = array::from_fn(|_| None); - answers[0] = Some(card[!side].display().clone()); - for i in 1..4 { - for _ in 0..12 { - answers[i] = Some( - set.cards.choose(&mut rand::thread_rng()).unwrap()[!side] - .display() - .clone(), - ); - if !answers[..i].contains(&answers[i]) { - break; - } - } - } - let mut answers = answers.map(Option::unwrap); - answers.shuffle(&mut rand::thread_rng()); - - question_box.update(|updater| { - updater.set_text(card[side].display().clone()); - }); - matching_answers_boxes.update(|updater| { - updater.set_outline(MultiOutlineType::DOUBLE_LIGHT); - for (index, text) in answers.iter().cloned().enumerate() { - updater.text(index).set(text).set_color(Color::White); - } - }); - io::stdout().flush().unwrap(); - loop { - match event::read().unwrap() { - Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers, - .. - }) if modifiers.contains(KeyModifiers::CONTROL) => { - term_settings.clear(); - panic!("Exited with ctrl-c"); - } - Event::Resize(x, y) => { - let term_size = Vec2::new(x, y); - let height_minus_padding = term_size.y.saturating_sub(7); - let box_height = height_minus_padding / 2; - - queue!(io::stdout(), terminal::Clear(ClearType::All)).unwrap(); - - question_box.force_move_resize(Rect { - size: Vec2::new(term_size.x / 3, box_height), - pos: Vec2::new(term_size.x / 3, 2), - }); - - matching_answers_boxes.force_move_resize(Rect { - size: Vec2::new(term_size.x.saturating_sub(8), box_height), - pos: Vec2::new( - 4, - term_size.y.saturating_sub(box_height).saturating_sub(3), - ), - }); - - io::stdout().flush().unwrap(); - } - Event::Key(KeyEvent { - code: KeyCode::Char(c), - .. - }) if ('1'..='4').contains(&c) => { - let index = match c { - '1' => 0, - '2' => 1, - '3' => 2, - '4' => 3, - _ => unreachable!(), - }; - - if card[!side] - .contains(&answers[index], cards.set.recall_settings(side)) - { - matching_answers_boxes.update(|updater| { - updater.text(index).set_color(Color::Green); - }); - io::stdout().flush().unwrap(); - loop { - if let Event::Key(_) = event::read().unwrap() { - break; - } - } - continue 'study_loop; - } else { - matching_answers_boxes.update(|updater| { - updater.text(index).set_color(Color::Red); - }); - io::stdout().flush().unwrap(); - } - } - _ => break, - } + .into(), + &set, + ); + + loop { + match event::read().unwrap() { + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers, + .. + }) if modifiers.contains(KeyModifiers::CONTROL) => { + term_settings.clear(); + println!("Exited with ctrl-c"); + return; + } + Event::Resize(x, y) => world.resize(Vec2::new(x, y)), + Event::Key(KeyEvent { + code: KeyCode::Char(c), + .. + }) => { + if world.key_pressed(c).is_break() { + break; } } - _ => todo!(), + _ => {} } } term_settings.clear(); } } - -struct CardListItem<'a> { - card: &'a Flashcard, - side: Side, - next_study_type: StudyType, -} - -struct CardList<'a> { - cards: Vec>, - set: &'a Set, - selected_index: usize, -} - -impl<'a> CardList<'a> { - fn from_set(set: &'a Set) -> Self { - let count = [set.recall_t.is_used(), set.recall_d.is_used()] - .into_iter() - .filter(|b| *b) - .count(); - let mut cards = Vec::with_capacity(count * set.cards.len()); - - let mut extend_cards = |recall_settings: &RecallSettings, side| { - let next_study_type = match *recall_settings { - RecallSettings { - matching: true, - text: true, - } => StudyType::Matching(true), - RecallSettings { - matching: true, - text: false, - } => StudyType::Matching(false), - RecallSettings { - matching: false, - text: true, - } => StudyType::Text(false), - RecallSettings { - matching: false, - text: false, - } => return, - }; - cards.extend(set.cards.iter().map(|card| CardListItem { - card, - side, - next_study_type, - })) - }; - - extend_cards(&set.recall_t, Side::Term); - extend_cards(&set.recall_d, Side::Definition); - - Self { - cards, - set, - selected_index: usize::MAX, - } - } - - fn select_next(&mut self) -> bool { - if self.cards.is_empty() { - false - } else { - let mut index = self.selected_index; - let mut counter = 0; - while index == self.selected_index && counter < 12 { - index = rand::thread_rng().gen_range(0..self.cards.len()); - } - self.selected_index = index; - true - } - } - - fn selected(&self) -> &CardListItem { - &self.cards[self.selected_index] - } -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] -enum StudyType { - Matching(bool), - Text(bool), -} - -impl StudyType { - fn index(self) -> usize { - match self { - StudyType::Matching(false) => 0, - StudyType::Matching(true) => 1, - StudyType::Text(false) => 2, - StudyType::Text(true) => 3, - } - } -} diff --git a/src/study/learn/world.rs b/src/study/learn/world.rs new file mode 100644 index 0000000..49f949a --- /dev/null +++ b/src/study/learn/world.rs @@ -0,0 +1,185 @@ +use std::{ + io::{self, Write}, + ops::ControlFlow, + rc::Rc, +}; + +use crossterm::{ + queue, + style::Color, + terminal::{self, ClearType}, +}; + +use crate::{ + flashcards::Set, + output::text_box::{MultiOutlineType, MultiTextBox, OutlineType, TextBox}, + vec2::{Rect, Vec2}, +}; + +use self::{ + card_list::{CardList, StudyType}, + footer::Footer, +}; + +mod card_list; +mod footer; + +#[derive(Debug)] +pub struct World<'a> { + question_box: TextBox>, + matching_answers_boxes: MultiTextBox, 4>, + footer: Footer, + card_list: CardList<'a>, + typ: Option, +} + +#[derive(Debug)] +enum WorldType { + Matching { + studying: card_list::Token, + failed: bool, + }, + WaitingForKeypress, +} + +impl<'a> World<'a> { + #[must_use] + pub fn new(size: Vec2, set: &'a Set) -> 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 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, size.y.saturating_sub(box_height).saturating_sub(3)), + }), + footer: Footer::new(card_list.len() as u32, size), + card_list, + 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.question_box.update(|updater| { + updater.set_text(item.card[!item.side].display().clone()); + }); + 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(_) => todo!(), + } + 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; + + 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, size.y.saturating_sub(box_height).saturating_sub(3)), + }); + + self.footer.resize(size); + + io::stdout().flush().unwrap(); + } + + pub fn key_pressed(&mut self, key: char) -> ControlFlow<()> { + match self.typ { + Some(WorldType::Matching { + studying, + ref mut failed, + }) => { + if ('1'..='4').contains(&key) { + let index = key as usize - '1' as usize; + let card = &self.card_list[studying]; + let text_color = match card.card[card.side].contains( + self.matching_answers_boxes.text(index).unwrap(), + self.card_list.recall_settings(card.side), + ) { + true => { + let failed = *failed; + self.typ = Some(WorldType::WaitingForKeypress); + let old_color = card.footer_color; + let new_color = self.card_list.progress(studying); + if !failed { + self.footer.r#move(old_color, new_color); + } + Color::Green + } + false => { + if !*failed { + *failed = true; + let old_color = card.footer_color; + if let Some(new_color) = self.card_list.regress(studying) { + self.footer.r#move(old_color, new_color); + } + } + Color::Red + } + }; + self.matching_answers_boxes.update(|updater| { + updater.text(index).set_color(text_color); + }); + io::stdout().flush().unwrap(); + } + ControlFlow::Continue(()) + } + Some(WorldType::WaitingForKeypress) => match self.study_next() { + true => ControlFlow::Continue(()), + false => ControlFlow::Break(()), + }, + None => ControlFlow::Break(()), + } + } +} diff --git a/src/study/learn/world/card_list.rs b/src/study/learn/world/card_list.rs new file mode 100644 index 0000000..65ac02c --- /dev/null +++ b/src/study/learn/world/card_list.rs @@ -0,0 +1,228 @@ +use std::{ + ops::{Index, IndexMut}, + rc::Rc, +}; + +use rand::{seq::SliceRandom, Rng}; + +use crate::flashcards::{Flashcard, RecallSettings, Set, Side}; + +use super::footer::FooterColor; + +#[derive(Debug)] +pub struct Item<'a> { + pub card: &'a Flashcard, + pub side: Side, + pub next_study_type: StudyType, + pub footer_color: FooterColor, +} + +/// 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>, + 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, + })) + } + }; + + extend_cards(term_start, Side::Term); + extend_cards(definition_start, Side::Definition); + + Self { 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), + }) + } + } + + /// Progresses the card specified and returns it's new footer color + pub fn progress(&mut self, card: Token) -> FooterColor { + let index = card.0; + let card = &self.cards[index]; + match card + .next_study_type + .progress(self.recall_settings(card.side)) + { + (Some(next_study_type), color) => { + self.cards[index].next_study_type = next_study_type; + color + } + (None, color) => { + self.cards.swap_remove(index); + color + } + } + } + + /// Regresses the card specified and returns it's new footer color + pub fn regress(&mut self, card: Token) -> Option { + let index = card.0; + let card = &self.cards[index]; + card.next_study_type + .regress(self.recall_settings(card.side)) + .map(|(next_study_type, color)| { + self.cards[index].next_study_type = next_study_type; + color + }) + } + + 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 len(&self) -> usize { + self.cards.len() + } +} + +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, + } + } +} diff --git a/src/study/learn/world/footer.rs b/src/study/learn/world/footer.rs new file mode 100644 index 0000000..7087e5f --- /dev/null +++ b/src/study/learn/world/footer.rs @@ -0,0 +1,92 @@ +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(); + } + + #[rustfmt::skip] + pub fn render(&self) { + queue!(io::stdout(), cursor::MoveTo(0, self.y)).unwrap(); + + let count = self.vals.into_iter().sum::() as f32; + let width = self.width as f32; + + fn print_section(amount: u32, width: u16, color: Color) { + 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(); + } + + let width = |val: u32| (((val as f32) / count) * width) as u16; + let green_width = width(self.vals[FooterColor::Green as usize]); + let yellow_width = width(self.vals[FooterColor::Yellow as usize]); + let red_width = width(self.vals[FooterColor::Red as usize]); + + print_section(self.vals[FooterColor::Green as usize], green_width, Color::Green); + print_section(self.vals[FooterColor::Yellow as usize], yellow_width, Color::Yellow); + print_section(self.vals[FooterColor::Red as usize], red_width, Color::Red); + print_section( + self.vals[FooterColor::Black as usize], + self.width + .saturating_sub(green_width) + .saturating_sub(yellow_width) + .saturating_sub(red_width), + Color::Black, + ); + + queue!(io::stdout(), style::SetBackgroundColor(Color::Reset)).unwrap(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FooterColor { + Black = 0, + Red = 1, + Yellow = 2, + Green = 3, +} From 1bff8ab3b0e7b1e2d10848067f9c3e3f00538721 Mon Sep 17 00:00:00 2001 From: NonbinaryCoder Date: Sat, 5 Nov 2022 11:48:15 -0400 Subject: [PATCH 5/8] Added text input box --- src/output.rs | 35 +++++-- src/output/text_box.rs | 59 ++++++++++-- src/output/text_box/input.rs | 178 +++++++++++++++++++++++++++++++++++ src/output/word_wrap.rs | 5 + src/study/flashcards.rs | 4 +- src/study/learn.rs | 1 + src/study/learn/world.rs | 36 ++++++- 7 files changed, 293 insertions(+), 25 deletions(-) create mode 100644 src/output/text_box/input.rs diff --git a/src/output.rs b/src/output.rs index 550814e..d350bc2 100644 --- a/src/output.rs +++ b/src/output.rs @@ -13,6 +13,22 @@ 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); @@ -131,14 +147,13 @@ pub struct TextAlign { } impl TextAlign { - pub fn center() -> Self { - Self { - h: TextAlignH::Center, - v: TextAlignV::Center, - } - } - - pub fn new(h: TextAlignH, v: TextAlignV) -> TextAlign { - Self { h, v } - } + pub const CENTER: Self = Self { + h: TextAlignH::Center, + v: TextAlignV::Center, + }; + + pub const TOP_LEFT: Self = Self { + h: TextAlignH::Left, + v: TextAlignV::Top, + }; } diff --git a/src/output/text_box.rs b/src/output/text_box.rs index c231d3f..eccded2 100644 --- a/src/output/text_box.rs +++ b/src/output/text_box.rs @@ -10,7 +10,9 @@ use crate::{ vec2::{Rect, Vec2}, }; -use super::{TextAlign, TextAlignH, TextAlignV}; +use super::{TextAlign, TextAlignV}; + +pub mod input; #[derive(Debug)] pub struct TextBox> { @@ -29,7 +31,7 @@ impl> TextBox { outline_type: None, outline_color: Color::White, text: None, - text_align: TextAlign::center(), + text_align: TextAlign::CENTER, text_color: Color::White, } } @@ -90,7 +92,7 @@ impl> TextBox { old_text.as_ref(), self.text_align, "", - TextAlign::new(TextAlignH::Left, TextAlignV::Top), + TextAlign::TOP_LEFT, ); self.text = None; } @@ -126,6 +128,22 @@ impl> TextBox { } } + pub fn hide(&mut self) { + if let Some(old_text) = self.text.as_ref() { + overwrite_text( + self.inner_size(), + self.text_color, + old_text.as_ref(), + self.text_align, + "", + TextAlign::TOP_LEFT, + ); + } + if self.outline_type.is_some() { + draw_outline(self.dims, self.outline_color, OutlineType::ERASE); + } + } + fn inner_size(&self) -> Rect { self.dims.shrink_centered(Vec2::splat(1)) } @@ -363,10 +381,6 @@ impl<'a, S: AsRef> TextBoxUpdater<'a, S> { pub fn set_color(&mut self, color: Color) -> &mut Self { self.set_text_color(color).set_outline_color(color) } - - pub fn clear_all(&mut self) -> &mut Self { - self.clear_outline().clear_text() - } } /// Sets `dst` to `new`, and returns true if they compare equal @@ -453,7 +467,7 @@ impl, const ITEMS: usize> MultiTextBox { outline_color: Color::White, items: [(); ITEMS].map(|()| MultiTextBoxItem { text: None, - align: TextAlign::center(), + align: TextAlign::CENTER, color: Color::White, }), } @@ -587,6 +601,14 @@ impl, const ITEMS: usize> MultiTextBox { } } + 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() } @@ -680,12 +702,27 @@ impl<'a, S: AsRef, const ITEMS: usize> MultiTextBoxUpdater<'a, S, ITEMS> { 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> { @@ -698,6 +735,12 @@ impl<'a, S: AsRef> MultiTextBoxItemUpdater<'a, S> { 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 { self.changes.redraw |= !set_and_compare(&mut self.item.color, color); self diff --git a/src/output/text_box/input.rs b/src/output/text_box/input.rs new file mode 100644 index 0000000..7d64120 --- /dev/null +++ b/src/output/text_box/input.rs @@ -0,0 +1,178 @@ +use std::io::{self, Write}; + +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + queue, + style::Color, +}; + +use crate::{ + output::{self, word_wrap::WordWrap, TerminalSettings, TextAlign}, + vec2::{Rect, Vec2}, +}; + +use super::{draw_outline, overwrite_text, OutlineType}; + +#[derive(Debug)] +pub struct TextInput { + dims: Rect, + outline_type: Option, + /// 0th buffer is the one that is currently visible + buffers: (String, String), +} + +impl TextInput { + pub fn new(dims: Rect) -> Self { + Self { + dims, + outline_type: None, + buffers: (String::new(), String::new()), + } + } + + pub fn get_input(&mut self, term_settings: &mut TerminalSettings) -> &str { + fn go_to_cursor(dims: Rect, buffer: &str, mut cursor_pos: usize) { + let mut last_len = buffer.len(); + let mut wrap = WordWrap::new(buffer, (dims.size.x - 2) as usize); + 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 { + queue!( + io::stdout(), + cursor::MoveTo( + dims.pos.x + 1 + (line[..cursor_pos].chars().count()) as u16, + dims.pos.y + 1 + y, + ), + ) + .unwrap(); + break; + } + last_len = len; + cursor_pos -= diff; + } else { + queue!( + io::stdout(), + cursor::MoveTo(dims.pos.x + 1, dims.pos.y + 1 + y,), + ) + .unwrap(); + break; + } + } + } + term_settings.show_cursor(); + let mut cursor_pos = self.buffers.0.len(); + go_to_cursor(self.dims, &self.buffers.0, cursor_pos); + io::stdout().flush().unwrap(); + loop { + match event::read().unwrap() { + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers, + .. + }) if modifiers.contains(KeyModifiers::CONTROL) => { + panic!("Exited with ctrl-c"); + } + Event::Key(KeyEvent { + code: KeyCode::Enter, + .. + }) => { + break; + } + Event::Key(KeyEvent { + code: KeyCode::Backspace, + .. + }) => { + if cursor_pos > 0 { + let idx = output::floor_char_boundary(&self.buffers.0, cursor_pos - 1); + let c = self.buffers.0.remove(idx); + overwrite_text( + self.inner_size(), + Color::Reset, + &self.buffers.1, + TextAlign::TOP_LEFT, + &self.buffers.0, + TextAlign::TOP_LEFT, + ); + self.buffers.1.remove(idx); + cursor_pos -= c.len_utf8(); + go_to_cursor(self.dims, &self.buffers.0, cursor_pos); + io::stdout().flush().unwrap(); + } + } + Event::Key(KeyEvent { + code: KeyCode::Left, + .. + }) => { + if cursor_pos > 0 { + cursor_pos = output::floor_char_boundary(&self.buffers.0, cursor_pos - 1); + go_to_cursor(self.dims, &self.buffers.0, cursor_pos); + io::stdout().flush().unwrap(); + } + } + Event::Key(KeyEvent { + code: KeyCode::Right, + .. + }) => { + if let Some(pos) = output::ceil_char_boundary(&self.buffers.0, cursor_pos + 1) { + cursor_pos = pos; + go_to_cursor(self.dims, &self.buffers.0, cursor_pos); + io::stdout().flush().unwrap(); + } + } + Event::Key(KeyEvent { + code: KeyCode::Char(c), + .. + }) => { + self.buffers.0.insert(cursor_pos, c); + overwrite_text( + self.inner_size(), + Color::Reset, + &self.buffers.1, + TextAlign::TOP_LEFT, + &self.buffers.0, + TextAlign::TOP_LEFT, + ); + self.buffers.1.insert(cursor_pos, c); + cursor_pos += c.len_utf8(); + go_to_cursor(self.dims, &self.buffers.0, cursor_pos); + io::stdout().flush().unwrap(); + } + _ => {} + } + } + term_settings.hide_cursor(); + &self.buffers.0 + } + + pub fn set_outline(&mut self, outline: OutlineType) -> &mut Self { + if self.outline_type != Some(outline) { + draw_outline(self.dims, Color::Reset, outline); + } + self + } + + pub fn hide(&mut self) { + if self.outline_type.is_some() { + draw_outline(self.dims, Color::White, OutlineType::ERASE); + } + if !self.buffers.0.is_empty() { + overwrite_text( + self.inner_size(), + Color::Reset, + &self.buffers.0, + TextAlign::TOP_LEFT, + "", + TextAlign::TOP_LEFT, + ); + self.buffers.0.clear(); + self.buffers.1.clear(); + } + } + + fn inner_size(&self) -> Rect { + self.dims.shrink_centered(Vec2::splat(1)) + } +} 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/flashcards.rs b/src/study/flashcards.rs index 857f129..4be2853 100644 --- a/src/study/flashcards.rs +++ b/src/study/flashcards.rs @@ -221,9 +221,7 @@ impl Entry { } else { visible_cards[Vec2::new(x, card_count.y - 1) .index_row_major(card_count.x as usize)] - .update(|updater| { - updater.clear_all(); - }) + .hide() } } } diff --git a/src/study/learn.rs b/src/study/learn.rs index ca78432..2e34340 100644 --- a/src/study/learn.rs +++ b/src/study/learn.rs @@ -45,6 +45,7 @@ impl Entry { .expect("unable to get terminal size") .into(), &set, + &mut term_settings, ); loop { diff --git a/src/study/learn/world.rs b/src/study/learn/world.rs index 49f949a..4091994 100644 --- a/src/study/learn/world.rs +++ b/src/study/learn/world.rs @@ -12,7 +12,10 @@ use crossterm::{ use crate::{ flashcards::Set, - output::text_box::{MultiOutlineType, MultiTextBox, OutlineType, TextBox}, + output::{ + text_box::{input::TextInput, MultiOutlineType, MultiTextBox, OutlineType, TextBox}, + TerminalSettings, + }, vec2::{Rect, Vec2}, }; @@ -28,8 +31,10 @@ mod footer; pub struct World<'a> { question_box: TextBox>, matching_answers_boxes: MultiTextBox, 4>, + text_input: TextInput, footer: Footer, card_list: CardList<'a>, + term_settings: &'a mut TerminalSettings, typ: Option, } @@ -39,16 +44,20 @@ enum WorldType { studying: card_list::Token, failed: bool, }, + Text { + studying: card_list::Token, + }, WaitingForKeypress, } impl<'a> World<'a> { #[must_use] - pub fn new(size: Vec2, set: &'a Set) -> Self { + pub fn new(size: Vec2, set: &'a Set, term_settings: &'a mut 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( @@ -62,10 +71,15 @@ impl<'a> World<'a> { ), matching_answers_boxes: MultiTextBox::new(Rect { size: Vec2::new(size.x.saturating_sub(8), box_height), - pos: Vec2::new(4, size.y.saturating_sub(box_height).saturating_sub(3)), + 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.len() as u32, size), card_list, + term_settings, typ: None, }; @@ -94,13 +108,23 @@ impl<'a> World<'a> { } updater.set_outline(MultiOutlineType::DOUBLE_LIGHT); }); + self.text_input.hide(); self.typ = Some(WorldType::Matching { studying: token, failed: false, }); } - StudyType::Text(_) => todo!(), + StudyType::Text(_) => { + self.question_box.update(|updater| { + updater.set_text(item.card[!item.side].display().clone()); + }); + self.matching_answers_boxes.hide(); + self.text_input.set_outline(OutlineType::DOUBLE); + io::stdout().flush().unwrap(); + + let answer = self.text_input.get_input(self.term_settings); + } } true } @@ -175,6 +199,10 @@ impl<'a> World<'a> { } ControlFlow::Continue(()) } + Some(WorldType::Text { studying }) => { + self.text_input.get_input(self.term_settings); + ControlFlow::Break(()) + } Some(WorldType::WaitingForKeypress) => match self.study_next() { true => ControlFlow::Continue(()), false => ControlFlow::Break(()), From 179bc5b23826dbea16d6de1ad654cc6156cdf3c0 Mon Sep 17 00:00:00 2001 From: NonbinaryCoder Date: Sat, 12 Nov 2022 13:20:48 -0500 Subject: [PATCH 6/8] Improved matching in learn mode --- src/input.rs | 102 +------ src/input/macros.rs | 100 +++++++ src/input/text.rs | 447 +++++++++++++++++++++++++++++ src/output.rs | 186 +++++++++++- src/output/text_box.rs | 234 +++------------ src/output/text_box/input.rs | 178 ------------ src/study/learn.rs | 7 +- src/study/learn/world.rs | 177 +++++++++--- src/study/learn/world/card_list.rs | 32 ++- src/study/learn/world/footer.rs | 53 ++-- 10 files changed, 967 insertions(+), 549 deletions(-) create mode 100644 src/input/macros.rs create mode 100644 src/input/text.rs delete mode 100644 src/output/text_box/input.rs 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/output.rs b/src/output.rs index d350bc2..48c8446 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,4 +1,4 @@ -use std::{fmt::Display, io}; +use std::{borrow::Cow, fmt::Display, io}; use crossterm::{ cursor, execute, queue, @@ -6,6 +6,13 @@ use crossterm::{ terminal, }; +use crate::{ + output::word_wrap::WordWrap, + vec2::{Rect, Vec2}, +}; + +use self::text_box::OutlineType; + pub mod text_box; pub mod word_wrap; @@ -157,3 +164,180 @@ impl TextAlign { 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(), + cursor::MoveDown(1), + cursor::MoveToColumn(dims.pos.x), + style::Print(typ.v), + cursor::MoveRight(dims.size.x - 2), + style::Print(typ.v), + ) + .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 get_lines_iter( + size: Vec2, + text: &str, + align: TextAlignV, +) -> impl Iterator> { + enum LinesIter<'a> { + Top(std::iter::Take>), + 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() + } + } + } + } + } + + 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), + }, + ) + } + } +} + +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), + ) + .unwrap(); + } + } +} + +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_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(); + } + } + } + } + } +} diff --git a/src/output/text_box.rs b/src/output/text_box.rs index eccded2..1987675 100644 --- a/src/output/text_box.rs +++ b/src/output/text_box.rs @@ -1,4 +1,4 @@ -use std::{array, borrow::Cow, io}; +use std::{array, io}; use crossterm::{ cursor, queue, @@ -6,13 +6,11 @@ use crossterm::{ }; use crate::{ - output::{word_wrap::WordWrap, Repeat}, + output::Repeat, vec2::{Rect, Vec2}, }; -use super::{TextAlign, TextAlignV}; - -pub mod input; +use super::TextAlign; #[derive(Debug)] pub struct TextBox> { @@ -51,6 +49,7 @@ impl> TextBox { 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, @@ -60,13 +59,14 @@ impl> TextBox { let TextBoxUpdater { inner: _, new_text, + text_color_changed, new_text_align, redraw_text, redraw_outline, } = updater; if redraw_outline { - draw_outline( + super::draw_outline( self.dims, self.outline_color, self.outline_type.unwrap_or(OutlineType::ERASE), @@ -77,7 +77,7 @@ impl> TextBox { match (self.text.as_ref(), new_text) { (None, None) => {} (None, Some(new_text)) => { - draw_text( + super::draw_text( self.inner_size(), self.text_color, new_text.as_ref().map(AsRef::as_ref).unwrap_or_default(), @@ -86,24 +86,26 @@ impl> TextBox { self.text = new_text; } (Some(old_text), None) => { - overwrite_text( + 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)) => { - overwrite_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; } @@ -116,10 +118,10 @@ impl> TextBox { 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 { - draw_outline(self.dims, self.outline_color, outline_type); + super::draw_outline(self.dims, self.outline_color, outline_type); } if let Some(text) = &self.text { - draw_text( + super::draw_text( self.inner_size(), self.text_color, text.as_ref(), @@ -130,17 +132,18 @@ impl> TextBox { pub fn hide(&mut self) { if let Some(old_text) = self.text.as_ref() { - overwrite_text( + 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() { - draw_outline(self.dims, self.outline_color, OutlineType::ERASE); + super::draw_outline(self.dims, self.outline_color, OutlineType::ERASE); } } @@ -153,182 +156,11 @@ impl> TextBox { } } -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(), - cursor::MoveDown(1), - cursor::MoveToColumn(dims.pos.x), - style::Print(typ.v), - cursor::MoveRight(dims.size.x - 2), - style::Print(typ.v), - ) - .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 get_lines_iter( - size: Vec2, - text: &str, - align: TextAlignV, -) -> impl Iterator> { - enum LinesIter<'a> { - Top(std::iter::Take>), - 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() - } - } - } - } - } - - 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), - }, - ) - } - } -} - -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), - ) - .unwrap(); - } - } -} - -fn overwrite_text( - dims: Rect, - color: Color, - old_text: &str, - old_align: TextAlign, - new_text: &str, - new_align: TextAlign, -) { - 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), - ) - .unwrap(), - (Some(old_line), None) => queue!( - io::stdout(), - cursor::MoveTo( - dims.pos.x + old_align.h.padding_for(old_line, dims.size.x), - y - ), - style::Print(Repeat(' ', old_line.chars().count() as u16)), - ) - .unwrap(), - (Some(old_line), Some(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_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(); - } - } - } - } -} - #[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, @@ -351,7 +183,9 @@ impl<'a, S: AsRef> TextBoxUpdater<'a, S> { } pub fn set_text_color(&mut self, color: Color) -> &mut Self { - self.redraw_text |= !set_and_compare(&mut self.inner.text_color, color); + let changed = !set_and_compare(&mut self.inner.text_color, color); + self.text_color_changed |= changed; + self.redraw_text |= changed; self } @@ -392,12 +226,12 @@ fn set_and_compare(dst: &mut T, new: T) -> bool { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct OutlineType { - tl: char, - tr: char, - bl: char, - br: char, - h: char, - v: char, + pub tl: char, + pub tr: char, + pub bl: char, + pub br: char, + pub h: char, + pub v: char, } #[allow(dead_code)] @@ -496,6 +330,7 @@ impl, const ITEMS: usize> MultiTextBox { 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, }), @@ -524,6 +359,7 @@ impl, const ITEMS: usize> MultiTextBox { ( MultiTextBoxItemChanges { new_text, + text_color_changed, new_align, redraw, }, @@ -542,7 +378,7 @@ impl, const ITEMS: usize> MultiTextBox { match (item.text.as_ref(), new_text) { (None, None) => {} (None, Some(text)) => { - draw_text( + super::draw_text( dims, item.color, text.as_ref().map(AsRef::as_ref).unwrap_or_default(), @@ -551,16 +387,17 @@ impl, const ITEMS: usize> MultiTextBox { item.text = text; } (Some(text), None) => { - draw_text(dims, item.color, text.as_ref(), item.align); + super::draw_text(dims, item.color, text.as_ref(), item.align); } (Some(old_text), Some(new_text)) => { - overwrite_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; } @@ -587,7 +424,7 @@ impl, const ITEMS: usize> MultiTextBox { let mut selected_pos = new_dims.pos + Vec2::splat(1); for item in &self.items { if let Some(text) = &item.text { - draw_text( + super::draw_text( Rect { pos: selected_pos, size: self.item_size, @@ -686,6 +523,7 @@ pub struct MultiTextBoxUpdater<'a, S: AsRef, const ITEMS: usize> { #[derive(Debug)] pub struct MultiTextBoxItemChanges> { new_text: Option>, + text_color_changed: bool, new_align: TextAlign, redraw: bool, } @@ -742,7 +580,9 @@ impl<'a, S: AsRef> MultiTextBoxItemUpdater<'a, S> { } pub fn set_color(&mut self, color: Color) -> &mut Self { - self.changes.redraw |= !set_and_compare(&mut self.item.color, color); + let changed = !set_and_compare(&mut self.item.color, color); + self.changes.text_color_changed |= changed; + self.changes.redraw |= changed; self } } diff --git a/src/output/text_box/input.rs b/src/output/text_box/input.rs deleted file mode 100644 index 7d64120..0000000 --- a/src/output/text_box/input.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::io::{self, Write}; - -use crossterm::{ - cursor, - event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, - queue, - style::Color, -}; - -use crate::{ - output::{self, word_wrap::WordWrap, TerminalSettings, TextAlign}, - vec2::{Rect, Vec2}, -}; - -use super::{draw_outline, overwrite_text, OutlineType}; - -#[derive(Debug)] -pub struct TextInput { - dims: Rect, - outline_type: Option, - /// 0th buffer is the one that is currently visible - buffers: (String, String), -} - -impl TextInput { - pub fn new(dims: Rect) -> Self { - Self { - dims, - outline_type: None, - buffers: (String::new(), String::new()), - } - } - - pub fn get_input(&mut self, term_settings: &mut TerminalSettings) -> &str { - fn go_to_cursor(dims: Rect, buffer: &str, mut cursor_pos: usize) { - let mut last_len = buffer.len(); - let mut wrap = WordWrap::new(buffer, (dims.size.x - 2) as usize); - 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 { - queue!( - io::stdout(), - cursor::MoveTo( - dims.pos.x + 1 + (line[..cursor_pos].chars().count()) as u16, - dims.pos.y + 1 + y, - ), - ) - .unwrap(); - break; - } - last_len = len; - cursor_pos -= diff; - } else { - queue!( - io::stdout(), - cursor::MoveTo(dims.pos.x + 1, dims.pos.y + 1 + y,), - ) - .unwrap(); - break; - } - } - } - term_settings.show_cursor(); - let mut cursor_pos = self.buffers.0.len(); - go_to_cursor(self.dims, &self.buffers.0, cursor_pos); - io::stdout().flush().unwrap(); - loop { - match event::read().unwrap() { - Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers, - .. - }) if modifiers.contains(KeyModifiers::CONTROL) => { - panic!("Exited with ctrl-c"); - } - Event::Key(KeyEvent { - code: KeyCode::Enter, - .. - }) => { - break; - } - Event::Key(KeyEvent { - code: KeyCode::Backspace, - .. - }) => { - if cursor_pos > 0 { - let idx = output::floor_char_boundary(&self.buffers.0, cursor_pos - 1); - let c = self.buffers.0.remove(idx); - overwrite_text( - self.inner_size(), - Color::Reset, - &self.buffers.1, - TextAlign::TOP_LEFT, - &self.buffers.0, - TextAlign::TOP_LEFT, - ); - self.buffers.1.remove(idx); - cursor_pos -= c.len_utf8(); - go_to_cursor(self.dims, &self.buffers.0, cursor_pos); - io::stdout().flush().unwrap(); - } - } - Event::Key(KeyEvent { - code: KeyCode::Left, - .. - }) => { - if cursor_pos > 0 { - cursor_pos = output::floor_char_boundary(&self.buffers.0, cursor_pos - 1); - go_to_cursor(self.dims, &self.buffers.0, cursor_pos); - io::stdout().flush().unwrap(); - } - } - Event::Key(KeyEvent { - code: KeyCode::Right, - .. - }) => { - if let Some(pos) = output::ceil_char_boundary(&self.buffers.0, cursor_pos + 1) { - cursor_pos = pos; - go_to_cursor(self.dims, &self.buffers.0, cursor_pos); - io::stdout().flush().unwrap(); - } - } - Event::Key(KeyEvent { - code: KeyCode::Char(c), - .. - }) => { - self.buffers.0.insert(cursor_pos, c); - overwrite_text( - self.inner_size(), - Color::Reset, - &self.buffers.1, - TextAlign::TOP_LEFT, - &self.buffers.0, - TextAlign::TOP_LEFT, - ); - self.buffers.1.insert(cursor_pos, c); - cursor_pos += c.len_utf8(); - go_to_cursor(self.dims, &self.buffers.0, cursor_pos); - io::stdout().flush().unwrap(); - } - _ => {} - } - } - term_settings.hide_cursor(); - &self.buffers.0 - } - - pub fn set_outline(&mut self, outline: OutlineType) -> &mut Self { - if self.outline_type != Some(outline) { - draw_outline(self.dims, Color::Reset, outline); - } - self - } - - pub fn hide(&mut self) { - if self.outline_type.is_some() { - draw_outline(self.dims, Color::White, OutlineType::ERASE); - } - if !self.buffers.0.is_empty() { - overwrite_text( - self.inner_size(), - Color::Reset, - &self.buffers.0, - TextAlign::TOP_LEFT, - "", - TextAlign::TOP_LEFT, - ); - self.buffers.0.clear(); - self.buffers.1.clear(); - } - } - - fn inner_size(&self) -> Rect { - self.dims.shrink_centered(Vec2::splat(1)) - } -} diff --git a/src/study/learn.rs b/src/study/learn.rs index 2e34340..a8fd3e4 100644 --- a/src/study/learn.rs +++ b/src/study/learn.rs @@ -60,11 +60,8 @@ impl Entry { return; } Event::Resize(x, y) => world.resize(Vec2::new(x, y)), - Event::Key(KeyEvent { - code: KeyCode::Char(c), - .. - }) => { - if world.key_pressed(c).is_break() { + Event::Key(event) => { + if world.key_pressed(event).is_break() { break; } } diff --git a/src/study/learn/world.rs b/src/study/learn/world.rs index 4091994..ac377fe 100644 --- a/src/study/learn/world.rs +++ b/src/study/learn/world.rs @@ -5,6 +5,7 @@ use std::{ }; use crossterm::{ + event::{KeyCode, KeyEvent}, queue, style::Color, terminal::{self, ClearType}, @@ -12,8 +13,9 @@ use crossterm::{ use crate::{ flashcards::Set, + input::text::TextInput, output::{ - text_box::{input::TextInput, MultiOutlineType, MultiTextBox, OutlineType, TextBox}, + text_box::{MultiOutlineType, MultiTextBox, OutlineType, TextBox}, TerminalSettings, }, vec2::{Rect, Vec2}, @@ -27,6 +29,11 @@ use self::{ 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>, @@ -47,6 +54,10 @@ enum WorldType { Text { studying: card_list::Token, }, + TextFailed { + studying: card_list::Token, + hidden_question: Option>, + }, WaitingForKeypress, } @@ -94,8 +105,12 @@ impl<'a> World<'a> { 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()); + 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 @@ -108,7 +123,6 @@ impl<'a> World<'a> { } updater.set_outline(MultiOutlineType::DOUBLE_LIGHT); }); - self.text_input.hide(); self.typ = Some(WorldType::Matching { studying: token, @@ -116,14 +130,23 @@ impl<'a> World<'a> { }); } StudyType::Text(_) => { + self.matching_answers_boxes.hide(); + self.question_box.update(|updater| { - updater.set_text(item.card[!item.side].display().clone()); + updater + .set_text(item.card[!item.side].display().clone()) + .set_text_color(Color::White); }); - self.matching_answers_boxes.hide(); - self.text_input.set_outline(OutlineType::DOUBLE); - io::stdout().flush().unwrap(); + 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(); - let answer = self.text_input.get_input(self.term_settings); + self.typ = Some(WorldType::Text { studying: token }); } } true @@ -140,6 +163,7 @@ impl<'a> World<'a> { 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(); @@ -150,7 +174,12 @@ impl<'a> World<'a> { self.matching_answers_boxes.force_move_resize(Rect { size: Vec2::new(size.x.saturating_sub(8), box_height), - pos: Vec2::new(4, size.y.saturating_sub(box_height).saturating_sub(3)), + 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); @@ -158,50 +187,124 @@ impl<'a> World<'a> { io::stdout().flush().unwrap(); } - pub fn key_pressed(&mut self, key: char) -> ControlFlow<()> { + pub fn key_pressed(&mut self, event: KeyEvent) -> ControlFlow<()> { + let code = event.code; match self.typ { Some(WorldType::Matching { studying, ref mut failed, }) => { - if ('1'..='4').contains(&key) { - let index = key as usize - '1' as usize; - let card = &self.card_list[studying]; - let text_color = match card.card[card.side].contains( - self.matching_answers_boxes.text(index).unwrap(), - self.card_list.recall_settings(card.side), - ) { - true => { + 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); - let old_color = card.footer_color; - let new_color = self.card_list.progress(studying); if !failed { - self.footer.r#move(old_color, new_color); + self.card_list.progress(studying, &mut self.footer); } - Color::Green - } - false => { + + 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; - let old_color = card.footer_color; - if let Some(new_color) = self.card_list.regress(studying) { - self.footer.r#move(old_color, new_color); - } + self.card_list.regress(studying, &mut self.footer); } - Color::Red - } - }; - self.matching_answers_boxes.update(|updater| { - updater.text(index).set_color(text_color); - }); - io::stdout().flush().unwrap(); + }; + + self.matching_answers_boxes.update(|updater| { + updater.text(index).set_color(text_color); + }); + io::stdout().flush().unwrap(); + } } ControlFlow::Continue(()) } Some(WorldType::Text { studying }) => { - self.text_input.get_input(self.term_settings); - ControlFlow::Break(()) + 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.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 { + 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.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(()), diff --git a/src/study/learn/world/card_list.rs b/src/study/learn/world/card_list.rs index 65ac02c..574495a 100644 --- a/src/study/learn/world/card_list.rs +++ b/src/study/learn/world/card_list.rs @@ -7,7 +7,7 @@ use rand::{seq::SliceRandom, Rng}; use crate::flashcards::{Flashcard, RecallSettings, Set, Side}; -use super::footer::FooterColor; +use super::footer::{Footer, FooterColor}; #[derive(Debug)] pub struct Item<'a> { @@ -92,35 +92,41 @@ impl<'a> CardList<'a> { } } - /// Progresses the card specified and returns it's new footer color - pub fn progress(&mut self, card: Token) -> FooterColor { + pub fn progress(&mut self, card: Token, footer: &mut Footer) { let index = card.0; let card = &self.cards[index]; - match card + 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) => { - self.cards[index].next_study_type = next_study_type; + let card = &mut self.cards[index]; + card.next_study_type = next_study_type; + card.footer_color = color; color } (None, color) => { self.cards.swap_remove(index); color } - } + }; + footer.r#move(old_color, new_color); } - /// Regresses the card specified and returns it's new footer color - pub fn regress(&mut self, card: Token) -> Option { + pub fn regress(&mut self, card: Token, footer: &mut Footer) { let index = card.0; let card = &self.cards[index]; - card.next_study_type + let old_color = card.footer_color; + if let Some((next_study_type, new_color)) = card + .next_study_type .regress(self.recall_settings(card.side)) - .map(|(next_study_type, color)| { - self.cards[index].next_study_type = next_study_type; - color - }) + { + 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 matching_answers_for(&self, card: &Item<'a>) -> [Rc; 4] { diff --git a/src/study/learn/world/footer.rs b/src/study/learn/world/footer.rs index 7087e5f..7304ec9 100644 --- a/src/study/learn/world/footer.rs +++ b/src/study/learn/world/footer.rs @@ -39,14 +39,18 @@ impl Footer { self.render(); } - #[rustfmt::skip] pub fn render(&self) { - queue!(io::stdout(), cursor::MoveTo(0, self.y)).unwrap(); + queue!( + io::stdout(), + cursor::MoveTo(0, self.y), + style::SetForegroundColor(Color::White), + ) + .unwrap(); - let count = self.vals.into_iter().sum::() as f32; - let width = self.width as f32; + let count = self.vals.into_iter().sum::() as f64; + let width = self.width as f64; - fn print_section(amount: u32, width: u16, color: Color) { + 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; @@ -60,26 +64,39 @@ impl Footer { style::Print(Repeat(' ', right_pad)), ) .unwrap(); + width } - let width = |val: u32| (((val as f32) / count) * width) as u16; - let green_width = width(self.vals[FooterColor::Green as usize]); - let yellow_width = width(self.vals[FooterColor::Yellow as usize]); - let red_width = width(self.vals[FooterColor::Red as usize]); + let mut leftover_width = self.width; + let width = |val: u32| (((val as f64) / count) * width) as u16; - print_section(self.vals[FooterColor::Green as usize], green_width, Color::Green); - print_section(self.vals[FooterColor::Yellow as usize], yellow_width, Color::Yellow); - print_section(self.vals[FooterColor::Red as usize], red_width, Color::Red); - print_section( + 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], - self.width - .saturating_sub(green_width) - .saturating_sub(yellow_width) - .saturating_sub(red_width), + width(self.vals[FooterColor::Black as usize]), Color::Black, ); - queue!(io::stdout(), style::SetBackgroundColor(Color::Reset)).unwrap(); + queue!( + io::stdout(), + style::Print(Repeat(' ', leftover_width)), + style::SetBackgroundColor(Color::Reset) + ) + .unwrap(); } } From fb69f041f38b44cdcc84a15936c60df7f8f0e458 Mon Sep 17 00:00:00 2001 From: NonbinaryCoder Date: Mon, 14 Nov 2022 16:55:28 -0500 Subject: [PATCH 7/8] Exiting learn mode now shows some stats matches made, match fails (& prop.), texts entered, text fails (& prop.), --- src/flashcards.rs | 4 +- src/output.rs | 21 +++++++ src/study/learn.rs | 7 +-- src/study/learn/world.rs | 86 ++++++++++++++++++++++++-- src/study/learn/world/card_list.rs | 97 +++++++++++++++++++++++++++++- 5 files changed, 200 insertions(+), 15 deletions(-) diff --git a/src/flashcards.rs b/src/flashcards.rs index 2b4cc34..6c13d6a 100644 --- a/src/flashcards.rs +++ b/src/flashcards.rs @@ -446,8 +446,8 @@ impl From<&str> for FlashcardText { #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Side { - Term, - Definition, + Term = 0, + Definition = 1, } impl Side { diff --git a/src/output.rs b/src/output.rs index 48c8446..1de4082 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,3 +1,4 @@ +use core::fmt; use std::{borrow::Cow, fmt::Display, io}; use crossterm::{ @@ -341,3 +342,23 @@ pub fn overwrite_text( } } } + +/// (total, fract) +#[derive(Debug, Clone, Copy)] +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/study/learn.rs b/src/study/learn.rs index a8fd3e4..cb77124 100644 --- a/src/study/learn.rs +++ b/src/study/learn.rs @@ -45,7 +45,7 @@ impl Entry { .expect("unable to get terminal size") .into(), &set, - &mut term_settings, + term_settings, ); loop { @@ -55,8 +55,7 @@ impl Entry { modifiers, .. }) if modifiers.contains(KeyModifiers::CONTROL) => { - term_settings.clear(); - println!("Exited with ctrl-c"); + world.print_stats(Some("Exited with ctrl-c")); return; } Event::Resize(x, y) => world.resize(Vec2::new(x, y)), @@ -69,6 +68,6 @@ impl Entry { } } - term_settings.clear(); + world.print_stats(None); } } diff --git a/src/study/learn/world.rs b/src/study/learn/world.rs index ac377fe..361f76c 100644 --- a/src/study/learn/world.rs +++ b/src/study/learn/world.rs @@ -7,16 +7,16 @@ use std::{ use crossterm::{ event::{KeyCode, KeyEvent}, queue, - style::Color, + style::{Color, Stylize}, terminal::{self, ClearType}, }; use crate::{ - flashcards::Set, + flashcards::{Set, Side}, input::text::TextInput, output::{ text_box::{MultiOutlineType, MultiTextBox, OutlineType, TextBox}, - TerminalSettings, + Proportion, TerminalSettings, }, vec2::{Rect, Vec2}, }; @@ -41,7 +41,9 @@ pub struct World<'a> { text_input: TextInput, footer: Footer, card_list: CardList<'a>, - term_settings: &'a mut TerminalSettings, + term_settings: TerminalSettings, + matches_made: [u32; 2], + texts_entered: [u32; 2], typ: Option, } @@ -63,7 +65,7 @@ enum WorldType { impl<'a> World<'a> { #[must_use] - pub fn new(size: Vec2, set: &'a Set, term_settings: &'a mut TerminalSettings) -> Self { + 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); @@ -88,9 +90,11 @@ impl<'a> World<'a> { size: Vec2::new(size.x / 3, box_height), pos: Vec2::new(size.x / 3, bottom_box_y), }), - footer: Footer::new(card_list.len() as u32, size), + footer: Footer::new(card_list.remaining_unstudied() as u32, size), card_list, term_settings, + matches_made: [0, 0], + texts_entered: [0, 0], typ: None, }; @@ -208,6 +212,7 @@ impl<'a> World<'a> { 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); } @@ -220,6 +225,8 @@ impl<'a> World<'a> { 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); } }; @@ -238,6 +245,7 @@ impl<'a> World<'a> { 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 @@ -248,6 +256,7 @@ impl<'a> World<'a> { 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 @@ -256,6 +265,7 @@ impl<'a> World<'a> { }); 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(); @@ -313,4 +323,68 @@ impl<'a> World<'a> { 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]; + for fail in card_list.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; + } + + // Matches made + let mm: u32 = matches_made.into_iter().sum(); + if mm > 0 { + let tmm = matches_made[Side::Term as usize]; + let dmm = matches_made[Side::Definition as usize]; + println!("Matches made: |{mm:^10}|{tmm:^10}|{dmm:^10}|"); + + // Match fails + let mf: u32 = match_fails.into_iter().sum(); + let tmf = match_fails[Side::Term as usize]; + let dmf = match_fails[Side::Definition as usize]; + println!("Match fails: |{mf:^10}|{tmf:^10}|{dmf:^10}|"); + + // Proportions + let mfp = Proportion(mm, mf); + let tmfp = Proportion(tmm, tmf); + let dmfp = Proportion(dmm, dmf); + println!(" |{mfp:^10}|{tmfp:^10}|{dmfp:^10}|"); + } + + // Texts entered + let te: u32 = texts_entered.into_iter().sum(); + if te > 0 { + let tte = texts_entered[Side::Term as usize]; + let dte = texts_entered[Side::Definition as usize]; + println!("Texts entered: |{te:^10}|{tte:^10}|{dte:^10}|"); + + // Text fails + let tf: u32 = text_fails.into_iter().sum(); + let ttf = text_fails[Side::Term as usize]; + let dtf = text_fails[Side::Definition as usize]; + println!("Text fails: |{tf:^10}|{ttf:^10}|{dtf:^10}|"); + + // Proportions + let tfp = Proportion(te, tf); + let ttfp = Proportion(tte, ttf); + let dtfp = Proportion(dte, dtf); + println!(" |{tfp:^10}|{ttfp:^10}|{dtfp:^10}|"); + } + } } diff --git a/src/study/learn/world/card_list.rs b/src/study/learn/world/card_list.rs index 574495a..4e0c346 100644 --- a/src/study/learn/world/card_list.rs +++ b/src/study/learn/world/card_list.rs @@ -7,6 +7,8 @@ use rand::{seq::SliceRandom, Rng}; use crate::flashcards::{Flashcard, RecallSettings, Set, Side}; +use self::fail_count::FailCount; + use super::footer::{Footer, FooterColor}; #[derive(Debug)] @@ -15,6 +17,27 @@ pub struct Item<'a> { 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, + } + } } /// A token representing an item in a card list @@ -44,6 +67,7 @@ impl<'a, 'b> RefToken<'a, 'b> { #[derive(Debug)] pub struct CardList<'a> { cards: Vec>, + removed: Vec>, set: &'a Set, } @@ -64,6 +88,8 @@ impl<'a> CardList<'a> { side, next_study_type, footer_color: FooterColor::Black, + match_fails: FailCount::ZERO, + text_fails: FailCount::ZERO, })) } }; @@ -71,7 +97,11 @@ impl<'a> CardList<'a> { extend_cards(term_start, Side::Term); extend_cards(definition_start, Side::Definition); - Self { cards, set } + Self { + removed: Vec::new(), + cards, + set, + } } pub fn next_unstudied(&self, last: Option) -> Option> { @@ -107,7 +137,10 @@ impl<'a> CardList<'a> { color } (None, color) => { - self.cards.swap_remove(index); + let card = self.cards.swap_remove(index); + if card.match_fails.has_failed() || card.text_fails.has_failed() { + self.removed.push((&card).into()); + } color } }; @@ -129,6 +162,14 @@ impl<'a> CardList<'a> { } } + 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()); @@ -154,9 +195,16 @@ impl<'a> CardList<'a> { self.set.recall_settings(side) } - pub fn len(&self) -> usize { + pub fn remaining_unstudied(&self) -> usize { self.cards.len() } + + pub fn fails(&mut self) -> &[FailedItem] { + self.removed.extend(self.cards.iter().filter_map(|item| { + (item.match_fails.has_failed() || item.text_fails.has_failed()).then_some(item.into()) + })); + &self.removed + } } impl<'a> Index for CardList<'a> { @@ -232,3 +280,46 @@ impl StudyType { } } } + +pub mod fail_count { + use std::fmt; + + #[derive(Clone, Copy)] + 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 + } + } +} From 35d607e9fb5a7da21ab48ef56a383aed87e8e25c Mon Sep 17 00:00:00 2001 From: NonbinaryCoder Date: Thu, 17 Nov 2022 18:42:45 -0500 Subject: [PATCH 8/8] Finishing learn mode now displays even more stats --- src/study/learn/world.rs | 170 ++++++++++++++++++++++------- src/study/learn/world/card_list.rs | 22 +++- 2 files changed, 147 insertions(+), 45 deletions(-) diff --git a/src/study/learn/world.rs b/src/study/learn/world.rs index 361f76c..e94b827 100644 --- a/src/study/learn/world.rs +++ b/src/study/learn/world.rs @@ -1,13 +1,14 @@ use std::{ + fmt::Display, io::{self, Write}, - ops::ControlFlow, + ops::{Add, ControlFlow}, rc::Rc, }; use crossterm::{ event::{KeyCode, KeyEvent}, - queue, - style::{Color, Stylize}, + execute, queue, + style::{self, Color, Stylize}, terminal::{self, ClearType}, }; @@ -18,6 +19,7 @@ use crate::{ text_box::{MultiOutlineType, MultiTextBox, OutlineType, TextBox}, Proportion, TerminalSettings, }, + study::learn::world::card_list::fail_count::FailCount, vec2::{Rect, Vec2}, }; @@ -338,53 +340,139 @@ impl<'a> World<'a> { println!("{}", error_code.red()); } println!("{}", "Stats:".bold()); - println!(" | total | term |definition|"); + println!(" | total | term |definition|"); let mut match_fails = [0, 0]; let mut text_fails = [0, 0]; - for fail in card_list.fails() { + 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; } - // Matches made - let mm: u32 = matches_made.into_iter().sum(); - if mm > 0 { - let tmm = matches_made[Side::Term as usize]; - let dmm = matches_made[Side::Definition as usize]; - println!("Matches made: |{mm:^10}|{tmm:^10}|{dmm:^10}|"); - - // Match fails - let mf: u32 = match_fails.into_iter().sum(); - let tmf = match_fails[Side::Term as usize]; - let dmf = match_fails[Side::Definition as usize]; - println!("Match fails: |{mf:^10}|{tmf:^10}|{dmf:^10}|"); - - // Proportions - let mfp = Proportion(mm, mf); - let tmfp = Proportion(tmm, tmf); - let dmfp = Proportion(dmm, dmf); - println!(" |{mfp:^10}|{tmfp:^10}|{dmfp:^10}|"); + #[derive(Debug)] + struct StatsSection { + made: StatsLine, + fails: StatsLine, } - // Texts entered - let te: u32 = texts_entered.into_iter().sum(); - if te > 0 { - let tte = texts_entered[Side::Term as usize]; - let dte = texts_entered[Side::Definition as usize]; - println!("Texts entered: |{te:^10}|{tte:^10}|{dte:^10}|"); - - // Text fails - let tf: u32 = text_fails.into_iter().sum(); - let ttf = text_fails[Side::Term as usize]; - let dtf = text_fails[Side::Definition as usize]; - println!("Text fails: |{tf:^10}|{ttf:^10}|{dtf:^10}|"); - - // Proportions - let tfp = Proportion(te, tf); - let ttfp = Proportion(tte, ttf); - let dtfp = Proportion(dte, dtf); - println!(" |{tfp:^10}|{ttfp:^10}|{dtfp:^10}|"); + #[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 index 4e0c346..3f332c4 100644 --- a/src/study/learn/world/card_list.rs +++ b/src/study/learn/world/card_list.rs @@ -40,6 +40,12 @@ impl<'a> From<&Item<'a>> for FailedItem<'a> { } } +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); @@ -199,11 +205,11 @@ impl<'a> CardList<'a> { self.cards.len() } - pub fn fails(&mut self) -> &[FailedItem] { + 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()) })); - &self.removed + &mut self.removed } } @@ -282,9 +288,9 @@ impl StudyType { } pub mod fail_count { - use std::fmt; + use std::{fmt, ops::Add}; - #[derive(Clone, Copy)] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct FailCount(u8); impl fmt::Display for FailCount { @@ -322,4 +328,12 @@ pub mod fail_count { self.0 } } + + impl Add for FailCount { + type Output = FailCount; + + fn add(self, rhs: FailCount) -> Self::Output { + FailCount(self.0.saturating_add(rhs.0)) + } + } }