lunar-lib 0.11.0

Common utilities for lunar applications
Documentation
use std::{
    fmt::Display,
    io::{self, Stdout, Write},
};

use crossterm::{
    QueueableCommand,
    cursor::{MoveToColumn, MoveUp},
    event::{self, Event, KeyCode, KeyEvent},
    style::{Color, Print, PrintStyledContent, Stylize},
    terminal::{self, Clear, ClearType},
};

use crate::prompts::{AskPrompter, Choice, ChoicePrompt, ChoicePrompter, OkayPrompter};

const NUM_MAP: &str = "1234567890abcdefgimnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

/// A basic default implementation of a [`Prompter`] for CLI applications
#[derive(Debug)]
pub struct CliPrompter;

impl CliPrompter {
    fn draw_options<C: Display>(
        stdout: &mut Stdout,
        index: usize,
        options: &[Choice<C>],
    ) -> io::Result<()> {
        for (i, opt) in options.iter().enumerate() {
            stdout
                .queue(MoveToColumn(0))?
                .queue(Clear(ClearType::CurrentLine))?;

            if i == index {
                stdout
                    .queue(PrintStyledContent("> ".with(Color::Green).bold()))?
                    .queue(PrintStyledContent(
                        format!(
                            "[{shortcut}] {opt}\n",
                            shortcut = NUM_MAP.chars().nth(i).unwrap_or(' ')
                        )
                        .with(Color::Cyan)
                        .bold(),
                    ))?;
            } else {
                stdout.queue(Print(format!(
                    "  [{shortcut}] {opt}\n",
                    shortcut = NUM_MAP.chars().nth(i).unwrap_or(' ')
                )))?;
            }
        }
        stdout.queue(MoveToColumn(0))?.flush()
    }
}

impl ChoicePrompter for CliPrompter {
    fn choose<C: Display>(&self, mut prompt: ChoicePrompt<'_, C>) -> Option<C> {
        let option_count = prompt.options.len();
        if option_count == 0 {
            return None;
        }

        let mut selected_index = prompt.default.min(option_count - 1);

        let mut stdout = io::stdout();

        // Print prompt
        stdout
            .queue(PrintStyledContent(
                format!("==> {}\n", prompt.prompt).with(Color::Green).bold(),
            ))
            .ok()?;

        Self::draw_options(&mut stdout, selected_index, &prompt.options).ok()?;

        terminal::enable_raw_mode().ok()?;

        let result = loop {
            match event::read().ok()? {
                Event::Key(KeyEvent { code, kind, .. }) if kind.is_press() => match code {
                    KeyCode::Up | KeyCode::Char('k') => {
                        selected_index =
                            ((selected_index as isize - 1) as usize).rem_euclid(option_count);
                        stdout.queue(MoveUp(option_count as u16)).ok()?;
                        Self::draw_options(&mut stdout, selected_index, &prompt.options).ok()?;
                    }
                    KeyCode::Down | KeyCode::Char('j') => {
                        selected_index = (selected_index + 1) % option_count;
                        stdout.queue(MoveUp(option_count as u16)).ok()?;
                        Self::draw_options(&mut stdout, selected_index, &prompt.options).ok()?;
                    }
                    KeyCode::Enter => break Some(selected_index),
                    KeyCode::Esc | KeyCode::Char('q') => {
                        break None;
                    }
                    KeyCode::Char(c) => {
                        if let Some(index) = NUM_MAP.find(c)
                            && index < option_count
                        {
                            break Some(index);
                        }
                    }
                    _ => (),
                },
                _ => (),
            }
        };

        terminal::disable_raw_mode().ok()?;

        result.map(|i| prompt.options.remove(i).into_value())
    }
}

impl OkayPrompter for CliPrompter {
    fn okay<T: Display>(&self, msg: T) {
        let mut output = io::stdout();
        writeln!(output, "{msg}\nPress [ENTER] to continue.\n").unwrap();
        let _ = io::stdin().read_line(&mut String::new());
    }
}

impl AskPrompter for CliPrompter {
    fn ask<T: Display>(&self, msg: T, default: bool) -> Option<bool> {
        let prompt = if default { "[Y/n]" } else { "[y/N]" };

        let mut output = io::stdout();
        writeln!(output, "==> Confirmation\n{msg}\n{prompt}").unwrap();
        loop {
            let mut input = String::new();
            io::stdin().read_line(&mut input).ok()?;

            match input.to_ascii_lowercase().as_str().trim() {
                "" => return Some(default),
                "y" | "yes" => return Some(true),
                "n" | "no" => return Some(false),
                _ => writeln!(output, "Please enter 'y' for yes or 'n' for no.").unwrap(),
            }
        }
    }
}