flashed 0.10.1

A flashcard TUI
Documentation
#![warn(clippy::pedantic)]

use std::io::{self, stdout, Write};
use std::ops::ControlFlow;

#[cfg(feature = "clap")]
use clap::Parser;
use crossterm::event::KeyEvent;
#[cfg(feature = "ui")]
use crossterm::{
    cursor,
    event::{self, DisableMouseCapture, Event, KeyCode},
    execute, terminal,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use flashed::App;
use flashed::CardType;
use flashed::Opts;
use flashed::DeckError;
#[cfg(feature = "ui")]
use tuviv::{
    border::Border,
    le::{
        layout::{Rect, Vec2, TRBL},
        Alignment, Orientation,
    },
    prelude::*,
    widgets::{Flexbox, Paragraph as P},
    CrosstermBackend, Widget,
};

#[cfg(feature = "ui")]
mod theme;

#[cfg(not(feature = "ui"))]
fn main() {
    compile_error!("Really sorry but you don't have a ui enabled.");
}

#[cfg(feature = "ui")]
fn main() -> Result<(), DeckError> {
    use flashed::Deck;

    let opts = Opts::parse();

    // Open the cards
    let deck = Deck::new_from_path(&opts.input)?;

    // Create app
    let mut app = App::new(deck, opts);
    if !app.opts.reset {
        app.read_scores()?;
        app.retain_undue();
        app.order_playables();
    }

    // Set up the terminal
    let mut stdout = stdout();
    execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
    enable_raw_mode()?;
    let mut size = terminal::size()?;

    // Create the buffer to write to and its backend
    let mut buffer = tuviv::Buffer::new(Vec2::new(size.0.into(), size.1.into()));
    let backend = CrosstermBackend;

    // This stores whether the user has actually finished the cards
    // or if they have just exited with `q`
    let mut finished = true;

    loop {
        if app.unfinished_count() == 0 {
            break;
        }

        let new_size = terminal::size()?;
        if new_size != size {
            size = new_size;
            buffer = tuviv::Buffer::new(Vec2::new(size.0.into(), size.1.into()));
        }
        buffer.clear();
        ui(&app).render(Rect::new(0, 0, size.0.into(), size.1.into()), &mut buffer);
        backend.finish(&buffer, &mut stdout)?;
        stdout.flush()?;

        // Handle key input
        if let Event::Key(key) = event::read()? {
            if let ControlFlow::Break(()) = handle_input(&mut app, key)? {
                finished = false;
                break;
            }
        }
    }

    // Restore terminal
    disable_raw_mode()?;
    execute!(
        stdout,
        LeaveAlternateScreen,
        DisableMouseCapture,
        cursor::Show
    )?;

    app.write_scores()?;
    if finished {
        println!("All cards learned!");
    } else {
        println!("Exiting!");
    }

    Ok(())
}

fn handle_input(app: &mut App, key: KeyEvent) -> io::Result<ControlFlow<()>> {
    match app.card().inner.question_type {
        CardType::Memory => match key.code {
            KeyCode::Char('j') => {
                app.change_current_card_score(-1);
                app.next_card();
            }
            KeyCode::Char('k') => {
                app.change_current_card_score(1);
                app.next_card();
            }
            KeyCode::Char(' ') => {
                app.flip();
            }
            KeyCode::Enter => app.next_card(),
            KeyCode::Char('w') => {
                app.write_scores()?;
            }
            KeyCode::Char('q') => {
                return Ok(ControlFlow::Break(()));
            }
            _ => (),
        },
        CardType::Input => {
            if app.flipped {
                match key.code {
                    KeyCode::Char('q') => {
                        return Ok(ControlFlow::Break(()));
                    }
                    KeyCode::Enter => {
                        app.change_current_card_score(if app.text == app.card().inner.answer {
                            1
                        } else {
                            -1
                        });
                        app.next_card();
                    }
                    _ => (),
                }
            } else {
                match key.code {
                    KeyCode::Char(x) => app.text.push(x),
                    KeyCode::Backspace => {
                        let _ = app.text.pop();
                    }
                    KeyCode::Enter => app.flip(),
                    _ => (),
                }
            }
        }
    }
    Ok(ControlFlow::Continue(()))
}

#[cfg(feature = "ui")]
fn ui(app: &App) -> Box<impl Widget + '_> {
    Flexbox::new(Orientation::Vertical, false)
        .child(create_instructions(app).to_flex_child())
        .child(create_card(app).to_flex_child().expand(1))
        .child(create_stats(app).to_flex_child())
}

/// Creates the instructions
#[cfg(feature = "ui")]
fn create_instructions(app: &App) -> Box<impl Widget> {
    match app.card().inner.question_type {
        CardType::Memory => Flexbox::new(Orientation::Horizontal, false)
            .child(P::new("Press ".styled()).wrap(false).to_flex_child())
            .child(
                P::new("q".with_style(theme::KEY))
                    .wrap(false)
                    .to_flex_child(),
            )
            .child(P::new(" to exit, ".styled()).wrap(false).to_flex_child())
            .child(
                P::new("space".with_style(theme::KEY))
                    .wrap(false)
                    .to_flex_child(),
            )
            .child(P::new(" to flip, ".styled()).wrap(false).to_flex_child())
            .child(
                P::new("j".with_style(theme::KEY))
                    .wrap(false)
                    .to_flex_child(),
            )
            .child(P::new(" to demote, ".styled()).wrap(false).to_flex_child())
            .child(
                P::new("k".with_style(theme::KEY))
                    .wrap(false)
                    .to_flex_child(),
            )
            .child(P::new(" to promote.".styled()).wrap(false).to_flex_child()),
        CardType::Input => Flexbox::new(Orientation::Horizontal, false)
            .child(P::new("Press ".styled()).wrap(false).to_flex_child())
            .child(
                P::new("q".with_style(theme::KEY))
                    .wrap(false)
                    .to_flex_child(),
            )
            .child(P::new(" to exit, ".styled()).wrap(false).to_flex_child())
            .child(
                P::new("enter".with_style(theme::KEY))
                    .wrap(false)
                    .to_flex_child(),
            )
            .child(P::new(" to submit, ".styled()).wrap(false).to_flex_child()),
    }
}

/// Creates the card for the user to flip
#[cfg(feature = "ui")]
fn create_card(app: &App) -> Box<impl Widget + '_> {
    match app.card().inner.question_type {
        CardType::Memory => {
            let card = if app.flipped {
                P::new((&app.card().inner.answer).with_style(theme::CARD_FLIPPED))
            } else {
                P::new((&app.card().inner.question).with_style(theme::CARD))
            };

            let card = card.prefered_ratio(8.0).text_align(Alignment::Center);
            let card = card
                .create_paragraphs()
                .centered()
                .to_box_sizing()
                .padding(TRBL::new(1, 1, 1, 1))
                .border(Border::MODERN);
            card.centered() // align_y(Alignment::Center)
        }
        CardType::Input => {
            let card = if app.flipped {
                if app.text == app.card().inner.answer {
                    Flexbox::new(Orientation::Vertical, false)
                        .child(
                            P::new("[Correct]".with_style(theme::CORRECT))
                                .text_align(Alignment::Center)
                                .to_flex_child(),
                        )
                        .child(
                            P::new((&app.text).with_style(theme::YOUR_ANSWER))
                                .text_align(Alignment::Center)
                                .prefered_ratio(8.0)
                                .create_paragraphs()
                                .to_flex_child(),
                        )
                } else {
                    let mut fb = Flexbox::new(Orientation::Vertical, false).child(
                        P::new("[Incorrect]".with_style(theme::INCORRECT))
                            .text_align(Alignment::Center)
                            .to_flex_child(),
                    );
                    if !app.text.is_empty() {
                        fb = fb
                            .child(P::new("You wrote:".styled()).to_flex_child())
                            .child(
                                P::new((&app.text).with_style(theme::YOUR_ANSWER))
                                    .text_align(Alignment::Center)
                                    .prefered_ratio(8.0)
                                    .create_paragraphs()
                                    .to_box_sizing()
                                    .margin_left(4)
                                    .to_flex_child(),
                            );
                    }
                    fb = fb.child(P::new("Answer:".styled()).to_flex_child()).child(
                        P::new((&app.card().inner.answer).with_style(theme::CARD_FLIPPED))
                            .text_align(Alignment::Center)
                            .prefered_ratio(8.0)
                            .create_paragraphs()
                            .to_box_sizing()
                            .margin_left(4)
                            .to_flex_child(),
                    );
                    fb
                }
            } else {
                Flexbox::new(Orientation::Vertical, false)
                    .child(
                        P::new((&app.card().inner.question).with_style(theme::CARD))
                            .text_align(Alignment::Center)
                            .prefered_ratio(8.0)
                            .create_paragraphs()
                            .to_flex_child(),
                    )
                    .child(
                        P::new((&app.text).with_style(theme::YOUR_ANSWER))
                            .prefered_ratio(8.0)
                            .create_paragraphs()
                            .to_box_sizing()
                            .border(Border::MODERN)
                            .to_flex_child(),
                    )
            };

            let card = card
                .centered()
                .to_box_sizing()
                .padding(TRBL::new(1, 1, 1, 1))
                .border(Border::MODERN);
            card.centered() // align_y(Alignment::Center)
        }
    }
}

/// Creates the stats about the card and deck
#[cfg(feature = "ui")]
fn create_stats(app: &App) -> Box<impl Widget + '_> {
    // Write the stats
    Flexbox::new(Orientation::Horizontal, false)
        .child(P::new("Card Score: ".styled()).wrap(false).to_flex_child())
        .child(
            P::new(app.card_score().to_string().with_style(theme::STAT))
                .wrap(false)
                .to_flex_child(),
        )
        .child(P::new(", Correct: ".styled()).wrap(false).to_flex_child())
        .child(
            P::new(
                (app.cards().len() - app.unfinished_count())
                    .to_string()
                    .with_style(theme::STAT),
            )
            .wrap(false)
            .to_flex_child(),
        )
        .child(
            P::new(", Still to go: ".styled())
                .wrap(false)
                .to_flex_child(),
        )
        .child(
            P::new(app.unfinished_count().to_string().with_style(theme::STAT))
                .wrap(false)
                .to_flex_child(),
        )
        .child(P::new(", Total: ".styled()).wrap(false).to_flex_child())
        .child(
            P::new(app.cards().len().to_string().with_style(theme::STAT))
                .wrap(false)
                .to_flex_child(),
        )
}