aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoris2021-11-14 23:25:55 +0100
committerJoris2021-11-19 11:42:20 +0100
commit9f94611a42d41cf94cdccb00b5d2eec0d5d02970 (patch)
tree9bab5bc342e22aa38b13a2dbd3525bbfe2beedb5 /src
parent59c44b15010eea5490896a5b5d427b415ad6f56a (diff)
downloadflashcards-9f94611a42d41cf94cdccb00b5d2eec0d5d02970.tar.gz
flashcards-9f94611a42d41cf94cdccb00b5d2eec0d5d02970.tar.bz2
flashcards-9f94611a42d41cf94cdccb00b5d2eec0d5d02970.zip
Add initial working version
Diffstat (limited to 'src')
-rw-r--r--src/db/db.rs122
-rw-r--r--src/db/mod.rs1
-rw-r--r--src/db/sql/1-init.sql10
-rw-r--r--src/deck.rs59
-rw-r--r--src/gui/gui.rs34
-rw-r--r--src/gui/mod.rs2
-rw-r--r--src/gui/question.rs198
-rw-r--r--src/main.rs15
-rw-r--r--src/model/card.rs8
-rw-r--r--src/model/deck.rs5
-rw-r--r--src/model/difficulty.rs16
-rw-r--r--src/model/mod.rs3
-rw-r--r--src/space_repetition.rs361
-rw-r--r--src/util/event.rs75
-rw-r--r--src/util/mod.rs3
-rw-r--r--src/util/serialization.rs7
16 files changed, 919 insertions, 0 deletions
diff --git a/src/db/db.rs b/src/db/db.rs
new file mode 100644
index 0000000..93c9564
--- /dev/null
+++ b/src/db/db.rs
@@ -0,0 +1,122 @@
+use crate::{
+ model::{card::Card, deck::Entry},
+ space_repetition,
+ util::serialization,
+};
+use anyhow::Result;
+use rusqlite::{params, Connection};
+use rusqlite_migration::{Migrations, M};
+use std::time::SystemTime;
+
+pub fn init() -> Result<Connection> {
+ let mut conn = Connection::open("database.db")?;
+ let migrations = Migrations::new(vec![M::up(include_str!("sql/1-init.sql"))]);
+ migrations.to_latest(&mut conn)?;
+ Ok(conn)
+}
+
+pub fn add_missing_deck_entries(conn: &Connection, entries: Vec<Entry>) -> Result<()> {
+ let now = get_current_time()?;
+ let ready = now;
+ let day: u64 = 60 * 60 * 24;
+
+ let state = serde_json::to_string(&space_repetition::init())?;
+
+ for entry in entries {
+ let concat_1 = serialization::words_to_line(&entry.part_1);
+ let concat_2 = serialization::words_to_line(&entry.part_2);
+
+ for (i, w) in entry.part_1.iter().enumerate() {
+ let r = ready + (i as u64) * day;
+ insert(&conn, now, r, &w, &concat_2, &state)?;
+ }
+
+ for (i, w2) in entry.part_2.iter().enumerate() {
+ let r = ready + ((entry.part_1.len() + i) as u64) * day;
+ insert(&conn, now, r, &w2, &concat_1, &state)?;
+ }
+ }
+
+ delete_read_before(&conn, now)?;
+
+ Ok(())
+}
+
+fn insert(
+ conn: &Connection,
+ now: u64,
+ ready: u64,
+ question: &String,
+ responses: &String,
+ state: &String,
+) -> Result<()> {
+ conn.execute(
+ "
+ INSERT INTO cards (question, responses, state, created, deck_read, ready)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (question) DO UPDATE SET deck_read = ?, deleted = null
+ ",
+ params![question, responses, state, now, now, ready, now],
+ )?;
+
+ Ok(())
+}
+
+fn delete_read_before(conn: &Connection, t: u64) -> Result<()> {
+ conn.execute(
+ "UPDATE cards SET deleted = ? WHERE deck_read < ?",
+ params![t, t],
+ )?;
+
+ Ok(())
+}
+
+pub fn pick_ready(conn: &Connection) -> Option<Card> {
+ let now = get_current_time().ok()?;
+
+ let mut stmt = conn
+ .prepare(
+ "
+ SELECT question, responses, state
+ FROM cards
+ WHERE ready <= ? AND deleted IS NULL
+ ORDER BY ready
+ LIMIT 1
+ ",
+ )
+ .ok()?;
+
+ let mut rows = stmt.query([now]).ok()?;
+ let row = rows.next().ok()??;
+ let state_str: String = row.get(2).ok()?;
+ let responses_str: String = row.get(1).ok()?;
+
+ Some(Card {
+ question: row.get(0).ok()?,
+ responses: serialization::line_to_words(&responses_str),
+ state: serde_json::from_str(&state_str).ok()?,
+ })
+}
+
+pub fn update(conn: &Connection, question: &String, state: &space_repetition::State) -> Result<()> {
+ let now = get_current_time()?;
+ let ready = now + state.get_interval_seconds();
+ let state_str = serde_json::to_string(state)?;
+
+ conn.execute(
+ "
+ UPDATE cards
+ SET state = ?, updated = ?, ready = ?
+ WHERE question = ?
+ ",
+ params![state_str, now, ready, question],
+ )?;
+
+ Ok(())
+}
+
+fn get_current_time() -> Result<u64> {
+ Ok(SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)?
+ .as_secs())
+}
diff --git a/src/db/mod.rs b/src/db/mod.rs
new file mode 100644
index 0000000..dec1023
--- /dev/null
+++ b/src/db/mod.rs
@@ -0,0 +1 @@
+pub mod db;
diff --git a/src/db/sql/1-init.sql b/src/db/sql/1-init.sql
new file mode 100644
index 0000000..29d70ed
--- /dev/null
+++ b/src/db/sql/1-init.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS cards (
+ question VARCHAR PRIMARY KEY,
+ responses VARCHAR NOT NULL,
+ state VARCHAR NOT NULL,
+ created TIMESTAMP NOT NULL,
+ updated TIMESTAMP NULL,
+ deleted TIMESTAMP NULL,
+ deck_read TIMESTAMP NOT NULL,
+ ready TIMESTAMP NOT NULL
+)
diff --git a/src/deck.rs b/src/deck.rs
new file mode 100644
index 0000000..384ce19
--- /dev/null
+++ b/src/deck.rs
@@ -0,0 +1,59 @@
+use crate::{model::deck::Entry, util::serialization};
+use anyhow::{Result, Error};
+use std::fs::File;
+use std::io::{prelude::*, BufReader};
+use std::fmt;
+
+#[derive(Debug, Clone)]
+struct ParseError {
+ line: usize,
+ message: String,
+}
+
+impl fmt::Display for ParseError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{} (parsing line {})", self.message, self.line)
+ }
+}
+
+impl std::error::Error for ParseError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ None
+ }
+}
+
+
+pub fn read() -> Result<Vec<Entry>> {
+ let file = File::open("deck")?;
+ let reader = BufReader::new(file);
+ let mut entries: Vec<Entry> = Vec::new();
+
+ for (index, line) in reader.lines().enumerate() {
+ let line = line?;
+ let line = line.trim();
+
+ if !line.starts_with("#") && !line.is_empty() {
+ if !line.starts_with("-") {
+ return Err(Error::from(ParseError { line: index + 1, message: "an entry should starts with “-”.".to_string() }))
+ } else {
+ let translation = line[1..].trim().split(":").collect::<Vec<&str>>();
+ if translation.len() != 2 {
+ return Err(Error::from(ParseError { line: index + 1, message: "an entry should contain two parts separated by “:”.".to_string() }))
+ } else {
+ let t1 = translation[0].trim();
+ let t2 = translation[1].trim();
+ if t1.is_empty() || t2.is_empty() {
+ return Err(Error::from(ParseError { line: index + 1, message: "an entry should contain two parts separated by “:”.".to_string() }))
+ } else {
+ entries.push(Entry {
+ part_1: serialization::line_to_words(&t1.to_string()),
+ part_2: serialization::line_to_words(&t2.to_string()),
+ })
+ }
+ }
+ }
+ }
+ }
+
+ Ok(entries)
+}
diff --git a/src/gui/gui.rs b/src/gui/gui.rs
new file mode 100644
index 0000000..fdf686b
--- /dev/null
+++ b/src/gui/gui.rs
@@ -0,0 +1,34 @@
+use crate::{db::db, gui::question, space_repetition, util::event::Events};
+use anyhow::Result;
+use rusqlite::Connection;
+use std::io;
+use termion::{raw::IntoRawMode, screen::AlternateScreen};
+use tui::{backend::TermionBackend, Terminal};
+
+pub fn start(conn: &Connection) -> Result<()> {
+ let stdout = io::stdout().into_raw_mode()?;
+ let stdout = AlternateScreen::from(stdout);
+ let backend = TermionBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+
+ let events = Events::new();
+
+ loop {
+ match db::pick_ready(&conn) {
+ Some(card) => {
+ let difficulty =
+ question::ask(&mut terminal, &events, &card, "German".to_string())?;
+ db::update(
+ &conn,
+ &card.question,
+ &space_repetition::update(card.state, difficulty),
+ )?;
+ }
+ None => {
+ break;
+ }
+ }
+ }
+
+ Ok(())
+}
diff --git a/src/gui/mod.rs b/src/gui/mod.rs
new file mode 100644
index 0000000..cbf9675
--- /dev/null
+++ b/src/gui/mod.rs
@@ -0,0 +1,2 @@
+pub mod gui;
+pub mod question;
diff --git a/src/gui/question.rs b/src/gui/question.rs
new file mode 100644
index 0000000..a22b977
--- /dev/null
+++ b/src/gui/question.rs
@@ -0,0 +1,198 @@
+use crate::{
+ model::{card::Card, difficulty, difficulty::Difficulty},
+ util::event::{Event, Events},
+ util::serialization,
+};
+use anyhow::Result;
+use termion::event::Key;
+use tui::{
+ backend::Backend,
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Span, Spans, Text},
+ widgets::{Block, Borders, Paragraph, Wrap},
+ Terminal,
+};
+
+struct State {
+ pub input: String,
+ pub answer: Answer,
+}
+
+enum Answer {
+ Write,
+ Difficulty { difficulty: Difficulty },
+}
+
+pub fn ask<B: Backend>(
+ terminal: &mut Terminal<B>,
+ events: &Events,
+ card: &Card,
+ deck: String,
+) -> Result<Difficulty> {
+ let mut state = State {
+ input: String::new(),
+ answer: Answer::Write,
+ };
+
+ loop {
+ terminal.draw(|f| {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(2)
+ .constraints(
+ [
+ Constraint::Length(1),
+ Constraint::Percentage(30),
+ Constraint::Length(5),
+ Constraint::Percentage(30),
+ Constraint::Length(5),
+ ]
+ .as_ref(),
+ )
+ .split(f.size());
+
+ let d1 = Paragraph::new(format!("{}", deck))
+ .alignment(Alignment::Center)
+ .style(
+ Style::default()
+ .fg(Color::Blue)
+ .add_modifier(Modifier::BOLD),
+ );
+ f.render_widget(d1, chunks[0]);
+
+ let question = Paragraph::new(center_vertically(chunks[1], &card.question))
+ .style(match state.answer {
+ Answer::Write => {
+ if state.input == "" {
+ Style::default().fg(Color::Yellow)
+ } else {
+ Style::default()
+ }
+ }
+ _ => Style::default(),
+ })
+ .alignment(Alignment::Center);
+ f.render_widget(question, chunks[1]);
+
+ let answer = Paragraph::new(center_vertically(chunks[2], &state.input))
+ .style(match state.answer {
+ Answer::Write => Style::default(),
+ Answer::Difficulty { difficulty: _ } => {
+ if is_correct(&state.input, &card.responses) {
+ Style::default().fg(Color::Green)
+ } else {
+ Style::default().fg(Color::Red)
+ }
+ }
+ })
+ .alignment(Alignment::Center)
+ .block(Block::default().borders(Borders::ALL).title("Réponse"))
+ .wrap(Wrap { trim: true });
+ f.render_widget(answer, chunks[2]);
+
+ match state.answer {
+ Answer::Difficulty {
+ difficulty: selected,
+ } => {
+ if !is_correct(&state.input, &card.responses) || card.responses.len() > 1 {
+ let paragraph = Paragraph::new(center_vertically(
+ chunks[3],
+ &serialization::words_to_line(&card.responses),
+ ))
+ .alignment(Alignment::Center);
+ f.render_widget(paragraph, chunks[3]);
+ };
+
+ let difficulties = card.state.difficulties();
+ let l = difficulties.len();
+ let sep = Span::styled(" • ", Style::default());
+ let tabs = difficulties
+ .iter()
+ .enumerate()
+ .map(|(i, d)| {
+ let style = if *d == selected {
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::UNDERLINED)
+ } else {
+ Style::default().add_modifier(Modifier::DIM)
+ };
+ let d = Span::styled(difficulty::label(*d), style);
+ if i < l - 1 {
+ [d, sep.clone()].to_vec()
+ } else {
+ [d].to_vec()
+ }
+ })
+ .collect::<Vec<Vec<Span>>>()
+ .concat();
+ let p =
+ Paragraph::new(Text::from(Spans::from(tabs))).alignment(Alignment::Center);
+ f.render_widget(p, chunks[4]);
+ }
+ _ => {}
+ }
+ })?;
+
+ if let Event::Input(key) = events.next()? {
+ match state.answer {
+ Answer::Write => match key {
+ Key::Char('\n') => {
+ let difficulty = if is_correct(&state.input, &card.responses) {
+ Difficulty::Good
+ } else {
+ Difficulty::Again
+ };
+ state.answer = Answer::Difficulty { difficulty }
+ }
+ Key::Char(c) => {
+ state.input.push(c);
+ }
+ Key::Backspace => {
+ state.input.pop();
+ }
+ _ => {}
+ },
+ Answer::Difficulty {
+ difficulty: selected,
+ } => match key {
+ Key::Left => {
+ for d in relative_element(&card.state.difficulties(), &selected, -1).iter()
+ {
+ state.answer = Answer::Difficulty { difficulty: *d }
+ }
+ }
+ Key::Right => {
+ for d in relative_element(&card.state.difficulties(), &selected, 1).iter() {
+ state.answer = Answer::Difficulty { difficulty: *d }
+ }
+ }
+ Key::Char('\n') => return Ok(selected),
+ _ => {}
+ },
+ }
+ }
+ }
+}
+
+fn center_vertically(chunk: Rect, text: &String) -> String {
+ let text_lines = text.lines().count();
+ let chunk_inner_lines: usize = (chunk.height - 2).into();
+ let blank_lines = chunk_inner_lines - text_lines;
+ let newlines = "\n".repeat(blank_lines / 2);
+ format!("{}{}", newlines, text)
+}
+
+fn is_correct(input: &String, responses: &Vec<String>) -> bool {
+ responses.contains(input)
+}
+
+fn relative_element<T: Clone + PartialEq>(xs: &Vec<T>, x: &T, ri: i32) -> Option<T> {
+ let i = xs.iter().position(|t| t == x)? as i32 + ri;
+ if i >= 0 && i < xs.len() as i32 {
+ Some(xs[i as usize].clone())
+ } else {
+ None
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..4761366
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,15 @@
+mod db;
+mod deck;
+mod gui;
+mod model;
+mod space_repetition;
+mod util;
+
+use anyhow::Result;
+
+fn main() -> Result<()> {
+ let conn = db::db::init()?;
+ let entries = deck::read()?;
+ db::db::add_missing_deck_entries(&conn, entries)?;
+ gui::gui::start(&conn)
+}
diff --git a/src/model/card.rs b/src/model/card.rs
new file mode 100644
index 0000000..3ac395e
--- /dev/null
+++ b/src/model/card.rs
@@ -0,0 +1,8 @@
+use crate::space_repetition;
+
+#[derive(Debug)]
+pub struct Card {
+ pub question: String,
+ pub responses: Vec<String>,
+ pub state: space_repetition::State,
+}
diff --git a/src/model/deck.rs b/src/model/deck.rs
new file mode 100644
index 0000000..769b38c
--- /dev/null
+++ b/src/model/deck.rs
@@ -0,0 +1,5 @@
+#[derive(Debug, Clone)]
+pub struct Entry {
+ pub part_1: Vec<String>,
+ pub part_2: Vec<String>,
+}
diff --git a/src/model/difficulty.rs b/src/model/difficulty.rs
new file mode 100644
index 0000000..ea5a9ce
--- /dev/null
+++ b/src/model/difficulty.rs
@@ -0,0 +1,16 @@
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Difficulty {
+ Again,
+ Hard,
+ Good,
+ Easy,
+}
+
+pub fn label(difficulty: Difficulty) -> String {
+ match difficulty {
+ Difficulty::Again => "Recommencer".to_string(),
+ Difficulty::Hard => "Difficile".to_string(),
+ Difficulty::Good => "Bon".to_string(),
+ Difficulty::Easy => "Facile".to_string(),
+ }
+}
diff --git a/src/model/mod.rs b/src/model/mod.rs
new file mode 100644
index 0000000..bbd7891
--- /dev/null
+++ b/src/model/mod.rs
@@ -0,0 +1,3 @@
+pub mod card;
+pub mod deck;
+pub mod difficulty;
diff --git a/src/space_repetition.rs b/src/space_repetition.rs
new file mode 100644
index 0000000..25cae7f
--- /dev/null
+++ b/src/space_repetition.rs
@@ -0,0 +1,361 @@
+// SM2-Anki
+// https://gist.github.com/riceissa/1ead1b9881ffbb48793565ce69d7dbdd
+
+use crate::model::difficulty::{Difficulty, Difficulty::*};
+use serde::{Deserialize, Serialize};
+
+// Learning
+const LEARNING_INTERVALS: [f32; 2] = [
+ 1.0 / 60.0 / 24.0, // 1 minute
+ 10.0 / 60.0 / 24.0, // 10 minutes
+];
+
+// Ease
+const EASE_INIT: f32 = 2.5;
+const EASE_MIN: f32 = 1.3;
+
+// Interval
+const INTERVAL_INIT: f32 = 1.0;
+const INTERVAL_INIT_EASY: f32 = 4.0;
+const INTERVAL_MIN: f32 = 0.1;
+const INTERVAL_MAX: f32 = 36500.0;
+
+// Learned
+const EASE_AGAIN_SUB: f32 = 0.2;
+const EASE_HARD_SUB: f32 = 0.15;
+const EASE_EASY_ADD: f32 = 0.15;
+const INTERVAL_AGAIN_MUL: f32 = 0.7;
+const INTERVAL_HARD_MUL: f32 = 1.2;
+const INTERVAL_EASY_MUL: f32 = 1.3;
+
+// Relearning
+const RELEARNING_INTERVALS: [f32; 1] = [
+ 10.0 / 60.0 / 24.0, // 10 minutes
+];
+
+#[derive(Debug, PartialEq, Deserialize, Serialize)]
+pub enum State {
+ Learning {
+ step: usize,
+ },
+ Learned {
+ ease: f32, // ratio
+ interval: f32, // in days
+ },
+ Relearning {
+ step: usize,
+ ease: f32,
+ interval: f32,
+ },
+}
+
+pub fn init() -> State {
+ State::Learning { step: 0 }
+}
+
+impl State {
+ pub fn get_interval_seconds(&self) -> u64 {
+ let days = match self {
+ State::Learning { step } => LEARNING_INTERVALS[*step],
+ State::Learned { interval, .. } => *interval,
+ State::Relearning { step, .. } => RELEARNING_INTERVALS[*step],
+ };
+ (days * 24.0 * 60.0 * 60.0).round() as u64
+ }
+
+ pub fn difficulties(&self) -> Vec<Difficulty> {
+ match self {
+ State::Learning { .. } => [Again, Good, Easy].to_vec(),
+ State::Learned { .. } => [Again, Hard, Good, Easy].to_vec(),
+ State::Relearning { .. } => [Again, Good].to_vec(),
+ }
+ }
+}
+
+pub fn update(state: State, difficulty: Difficulty) -> State {
+ match state {
+ State::Learning { step } => match difficulty {
+ Again => State::Learning { step: 0 },
+ Good => {
+ let new_step = step + 1;
+ if new_step < LEARNING_INTERVALS.len() {
+ State::Learning { step: new_step }
+ } else {
+ State::Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT,
+ }
+ }
+ }
+ Easy => State::Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT_EASY,
+ },
+ _ => panic!("Learning is incompatible with {:?}", difficulty),
+ },
+ State::Learned { ease, interval } => match difficulty {
+ Again => State::Relearning {
+ step: 0,
+ ease: clamp_ease(ease - EASE_AGAIN_SUB),
+ interval: clamp_interval(interval * INTERVAL_AGAIN_MUL),
+ },
+ Hard => State::Learned {
+ ease: clamp_ease(ease - EASE_HARD_SUB),
+ interval: clamp_interval(interval * INTERVAL_HARD_MUL),
+ },
+ Good => State::Learned {
+ ease,
+ interval: clamp_interval(interval * ease),
+ },
+ Easy => State::Learned {
+ ease: clamp_ease(ease + EASE_EASY_ADD),
+ interval: clamp_interval(interval * ease * INTERVAL_EASY_MUL),
+ },
+ },
+ State::Relearning {
+ step,
+ ease,
+ interval,
+ } => match difficulty {
+ Again => State::Relearning {
+ step: 0,
+ ease,
+ interval,
+ },
+ Good => {
+ let new_step = step + 1;
+ if new_step < RELEARNING_INTERVALS.len() {
+ State::Relearning {
+ step: new_step,
+ ease,
+ interval,
+ }
+ } else {
+ State::Learned { ease, interval }
+ }
+ }
+ _ => panic!("Relearning is incompatible with {:?}.", difficulty),
+ },
+ }
+}
+
+fn clamp_ease(f: f32) -> f32 {
+ if f < EASE_MIN {
+ EASE_MIN
+ } else {
+ f
+ }
+}
+
+fn clamp_interval(i: f32) -> f32 {
+ if i < INTERVAL_MIN {
+ INTERVAL_MIN
+ } else if i > INTERVAL_MAX {
+ INTERVAL_MAX
+ } else {
+ i
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{State::*, *};
+
+ #[test]
+ fn learning_again() {
+ assert_eq!(update(Learning { step: 1 }, Again), Learning { step: 0 });
+ }
+
+ #[test]
+ fn learning_good() {
+ assert_eq!(update(Learning { step: 0 }, Good), Learning { step: 1 });
+
+ assert_eq!(
+ update(
+ Learning {
+ step: LEARNING_INTERVALS.len() - 1
+ },
+ Good
+ ),
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT
+ }
+ );
+ }
+
+ #[test]
+ fn learning_easy() {
+ assert_eq!(
+ update(Learning { step: 0 }, Easy),
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT_EASY
+ }
+ );
+ }
+
+ #[test]
+ fn learned_again() {
+ assert_eq!(
+ update(
+ Learned {
+ ease: EASE_MIN,
+ interval: INTERVAL_MIN
+ },
+ Again
+ ),
+ Relearning {
+ step: 0,
+ ease: EASE_MIN,
+ interval: INTERVAL_MIN
+ }
+ );
+
+ assert_eq!(
+ update(
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT
+ },
+ Again
+ ),
+ Relearning {
+ step: 0,
+ ease: EASE_INIT - EASE_AGAIN_SUB,
+ interval: INTERVAL_INIT * INTERVAL_AGAIN_MUL
+ }
+ );
+ }
+
+ #[test]
+ fn learned_hard() {
+ assert_eq!(
+ update(
+ Learned {
+ ease: EASE_MIN,
+ interval: INTERVAL_MAX
+ },
+ Hard
+ ),
+ Learned {
+ ease: EASE_MIN,
+ interval: INTERVAL_MAX
+ }
+ );
+
+ assert_eq!(
+ update(
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT
+ },
+ Hard
+ ),
+ Learned {
+ ease: EASE_INIT - EASE_HARD_SUB,
+ interval: INTERVAL_INIT * INTERVAL_HARD_MUL
+ }
+ );
+ }
+
+ #[test]
+ fn learned_good() {
+ assert_eq!(
+ update(
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_MAX
+ },
+ Good
+ ),
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_MAX
+ }
+ );
+
+ assert_eq!(
+ update(
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT
+ },
+ Good
+ ),
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT * EASE_INIT
+ }
+ );
+ }
+
+ #[test]
+ fn learned_easy() {
+ assert_eq!(
+ update(
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_MAX
+ },
+ Easy
+ ),
+ Learned {
+ ease: EASE_INIT + EASE_EASY_ADD,
+ interval: INTERVAL_MAX
+ }
+ );
+
+ assert_eq!(
+ update(
+ Learned {
+ ease: EASE_INIT,
+ interval: INTERVAL_INIT
+ },
+ Easy
+ ),
+ Learned {
+ ease: EASE_INIT + EASE_EASY_ADD,
+ interval: INTERVAL_INIT * EASE_INIT * INTERVAL_EASY_MUL
+ }
+ );
+ }
+
+ #[test]
+ fn relearning_again() {
+ let ease = EASE_INIT + EASE_EASY_ADD;
+ let interval = INTERVAL_INIT * ease;
+ assert_eq!(
+ update(
+ Relearning {
+ step: 1,
+ ease,
+ interval,
+ },
+ Again
+ ),
+ Relearning {
+ step: 0,
+ ease,
+ interval
+ }
+ );
+ }
+
+ #[test]
+ fn relearning_good() {
+ let ease = EASE_INIT + EASE_EASY_ADD;
+ let interval = INTERVAL_INIT * ease;
+ assert_eq!(
+ update(
+ Relearning {
+ step: RELEARNING_INTERVALS.len() - 1,
+ ease,
+ interval,
+ },
+ Good
+ ),
+ Learned { ease, interval }
+ );
+ }
+}
diff --git a/src/util/event.rs b/src/util/event.rs
new file mode 100644
index 0000000..33ee9ec
--- /dev/null
+++ b/src/util/event.rs
@@ -0,0 +1,75 @@
+use std::io;
+use std::sync::mpsc;
+use std::thread;
+use std::time::Duration;
+
+use termion::event::Key;
+use termion::input::TermRead;
+
+pub enum Event<I> {
+ Input(I),
+ Tick,
+}
+
+/// A small event handler that wrap termion input and tick events. Each event
+/// type is handled in its own thread and returned to a common `Receiver`
+pub struct Events {
+ rx: mpsc::Receiver<Event<Key>>,
+ input_handle: thread::JoinHandle<()>,
+ tick_handle: thread::JoinHandle<()>,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct Config {
+ pub tick_rate: Duration,
+}
+
+impl Default for Config {
+ fn default() -> Config {
+ Config {
+ tick_rate: Duration::from_millis(250),
+ }
+ }
+}
+
+impl Events {
+ pub fn new() -> Events {
+ Events::with_config(Config::default())
+ }
+
+ pub fn with_config(config: Config) -> Events {
+ let (tx, rx) = mpsc::channel();
+ let input_handle = {
+ let tx = tx.clone();
+ thread::spawn(move || {
+ let stdin = io::stdin();
+ for evt in stdin.keys() {
+ if let Ok(key) = evt {
+ if let Err(err) = tx.send(Event::Input(key)) {
+ eprintln!("{}", err);
+ return;
+ }
+ }
+ }
+ })
+ };
+ let tick_handle = {
+ thread::spawn(move || loop {
+ if let Err(err) = tx.send(Event::Tick) {
+ eprintln!("{}", err);
+ break;
+ }
+ thread::sleep(config.tick_rate);
+ })
+ };
+ Events {
+ rx,
+ input_handle,
+ tick_handle,
+ }
+ }
+
+ pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> {
+ self.rx.recv()
+ }
+}
diff --git a/src/util/mod.rs b/src/util/mod.rs
new file mode 100644
index 0000000..c5504af
--- /dev/null
+++ b/src/util/mod.rs
@@ -0,0 +1,3 @@
+#[allow(dead_code)]
+pub mod event;
+pub mod serialization;
diff --git a/src/util/serialization.rs b/src/util/serialization.rs
new file mode 100644
index 0000000..fcb3062
--- /dev/null
+++ b/src/util/serialization.rs
@@ -0,0 +1,7 @@
+pub fn line_to_words(line: &String) -> Vec<String> {
+ line.split("|").map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect()
+}
+
+pub fn words_to_line(words: &Vec<String>) -> String {
+ words.join(" | ")
+}