tmaze 1.12.1

Simple multiplatform maze solving game for terminal written entirely in Rust
Documentation
use crossterm::style::{Color, ContentStyle};
pub use crossterm::{
    event::{poll, read, Event, KeyCode, KeyEvent},
    terminal::size,
};

use pad::PadStr;
use std::cell::RefCell;

use crate::helpers::{is_release, value_if};
use crate::renderer::Renderer;

use super::draw::*;
use super::*;

#[derive(Debug)]
pub enum MenuError {
    CrosstermError(CrosstermError),
    EmptyMenu,
    Exit,
    FullQuit,
}

impl From<CrosstermError> for MenuError {
    fn from(error: CrosstermError) -> Self {
        Self::CrosstermError(error)
    }
}

impl From<crossterm::ErrorKind> for MenuError {
    fn from(error: crossterm::ErrorKind) -> Self {
        Self::CrosstermError(error.try_into().expect("Cannot convert crossterm error"))
    }
}

pub fn menu_size(title: &str, options: &[String], counted: bool) -> Dims {
    match options.iter().map(|opt| opt.len()).max() {
        Some(l) => Dims(
            ((2 + if counted {
                (options.len() + 1).to_string().len() + 2
            } else {
                0
            } + l
                - 2)
            .max(title.len() + 2)
                + 2) as i32
                + 2,
            options.len() as i32 + 2 + 2,
        ),
        None => Dims(0, 0),
    }
}

pub fn menu(
    renderer: &mut Renderer,
    box_style: ContentStyle,
    text_style: ContentStyle,
    title: &str,
    options: &[&str],
    default: Option<usize>,
    counted: bool,
) -> Result<u16, MenuError> {
    let mut selected = default.unwrap_or(0);
    let opt_count = options.len();

    if opt_count == 0 {
        return Err(MenuError::EmptyMenu);
    }

    let options = if default.is_some() {
        options
            .iter()
            .enumerate()
            .map(|(i, opt)| format!("{} {}", if i == default.unwrap() { "" } else { " " }, opt))
            .collect::<Vec<_>>()
    } else {
        options
            .iter()
            .map(|opt| String::from(*opt))
            .collect::<Vec<_>>()
    };

    render_menu(
        renderer, box_style, text_style, title, &options, selected, counted,
    )?;

    loop {
        let event = read()?;

        match event {
            Event::Key(KeyEvent { code, kind, .. }) if !is_release(kind) => match code {
                KeyCode::Up | KeyCode::Char('w') | KeyCode::Char('W') => {
                    selected = if selected == 0 {
                        opt_count - 1
                    } else {
                        selected - 1
                    }
                }
                KeyCode::Down | KeyCode::Char('s') | KeyCode::Char('S') => {
                    selected = (selected + 1) % opt_count
                }
                KeyCode::Enter | KeyCode::Char(' ') => return Ok(selected as u16),
                KeyCode::Char(ch) => {
                    if counted {
                        selected = match ch {
                            'q' | 'Q' => return Err(MenuError::FullQuit),
                            '1'..='9' => ch as usize - '1' as usize,
                            _ => selected,
                        }
                        .clamp(0, opt_count - 1);
                    }
                }
                KeyCode::Esc => return Err(MenuError::Exit),
                _ => {}
            },
            Event::Mouse(_) => {}
            _ => {}
        }

        renderer.on_event(&event)?;

        render_menu(
            renderer, box_style, text_style, title, &options, selected, counted,
        )?;
    }
}

pub fn choice_menu<'a, T>(
    renderer: &mut Renderer,
    box_style: ContentStyle,
    text_style: ContentStyle,
    title: &str,
    options: &'a [(T, &str)],
    default: Option<usize>,
    counted: bool,
) -> Result<&'a T, MenuError> {
    let _options: Vec<&str> = options.iter().map(|opt| opt.1).collect();
    Ok(&options[menu(
        renderer, box_style, text_style, title, &_options, default, counted,
    )? as usize]
        .0)
}

pub fn render_menu(
    renderer: &mut Renderer,
    box_style: ContentStyle,
    text_style: ContentStyle,
    title: &str,
    options: &[String],
    selected: usize,
    counted: bool,
) -> Result<(), CrosstermError> {
    let menu_size = menu_size(title, options, counted);
    let pos = box_center_screen(menu_size)?;
    let opt_count = options.len();

    let max_count = opt_count.to_string().len();

    {
        let mut context = DrawContext {
            renderer: &RefCell::new(renderer),
            style: box_style,
            frame: None,
        };

        context.draw_box(pos, menu_size);

        context.draw_str_styled(pos + Dims(3, 1), title, text_style);
        context.draw_str(pos + Dims(1, 2), &"".repeat(menu_size.0 as usize - 2));

        for (i, option) in options.iter().enumerate() {
            let style = if i == selected {
                ContentStyle {
                    background_color: Some(text_style.foreground_color.unwrap_or(Color::White)),
                    foreground_color: Some(text_style.background_color.unwrap_or(Color::Black)),
                    underline_color: None,
                    attributes: Default::default(),
                }
            } else {
                text_style
            };

            context.draw_str_styled(
                pos + Dims(1, i as i32 + 3),
                &format!(
                    "{} {}{}",
                    if i == selected { ">" } else { " " },
                    value_if(counted, || format!("{}.", i + 1)
                        .pad_to_width((max_count as f64).log10().floor() as usize + 3)),
                    option,
                )
                .pad_to_width(menu_size.0 as usize - 2),
                style,
            );
        }
    }

    renderer.render()?;

    Ok(())
}