From 9f94611a42d41cf94cdccb00b5d2eec0d5d02970 Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 14 Nov 2021 23:25:55 +0100 Subject: Add initial working version --- src/db/db.rs | 122 ++++++++++++++++ src/db/mod.rs | 1 + src/db/sql/1-init.sql | 10 ++ src/deck.rs | 59 ++++++++ src/gui/gui.rs | 34 +++++ src/gui/mod.rs | 2 + src/gui/question.rs | 198 +++++++++++++++++++++++++ src/main.rs | 15 ++ src/model/card.rs | 8 + src/model/deck.rs | 5 + src/model/difficulty.rs | 16 ++ src/model/mod.rs | 3 + src/space_repetition.rs | 361 ++++++++++++++++++++++++++++++++++++++++++++++ src/util/event.rs | 75 ++++++++++ src/util/mod.rs | 3 + src/util/serialization.rs | 7 + 16 files changed, 919 insertions(+) create mode 100644 src/db/db.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/sql/1-init.sql create mode 100644 src/deck.rs create mode 100644 src/gui/gui.rs create mode 100644 src/gui/mod.rs create mode 100644 src/gui/question.rs create mode 100644 src/main.rs create mode 100644 src/model/card.rs create mode 100644 src/model/deck.rs create mode 100644 src/model/difficulty.rs create mode 100644 src/model/mod.rs create mode 100644 src/space_repetition.rs create mode 100644 src/util/event.rs create mode 100644 src/util/mod.rs create mode 100644 src/util/serialization.rs (limited to 'src') 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 { + 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) -> 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 { + 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 { + 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> { + let file = File::open("deck")?; + let reader = BufReader::new(file); + let mut entries: Vec = 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::>(); + 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( + terminal: &mut Terminal, + events: &Events, + card: &Card, + deck: String, +) -> Result { + 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::>>() + .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) -> bool { + responses.contains(input) +} + +fn relative_element(xs: &Vec, x: &T, ri: i32) -> Option { + 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, + 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, + pub part_2: Vec, +} 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 { + 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 { + 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>, + 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, 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 { + line.split("|").map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect() +} + +pub fn words_to_line(words: &Vec) -> String { + words.join(" | ") +} -- cgit v1.2.3