cactui 0.1.0

Terminal-based interactive prompts and key menus for CLI applications
Documentation
use anyhow::Result;
use crossterm::{
    cursor::{Hide, MoveToColumn, MoveUp, Show},
    event::{
        self, Event, KeyCode, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, poll,
    },
    execute, queue,
    style::{Color, Print, ResetColor, SetForegroundColor},
    terminal::{Clear, ClearType, disable_raw_mode, enable_raw_mode},
};
use std::io::{self, Write};
use std::time::Duration;

/// Options for configuring inline input behavior
#[derive(Debug, Clone)]
pub struct InlineInputOpts {
    placeholder: Option<String>,
    escape_to_exit: bool,
}

impl InlineInputOpts {
    /// Create new input options with default settings
    pub fn new() -> Self {
        Self {
            placeholder: None,
            escape_to_exit: false,
        }
    }

    /// Set placeholder text that will be pre-filled in the input
    pub fn placeholder<S: Into<String>>(mut self, text: S) -> Self {
        self.placeholder = Some(text.into());
        self
    }

    /// Enable escape-to-exit mode where:
    /// - Escape exits the input
    /// - Enter inserts newlines instead of submitting
    /// - Shift+Enter still inserts newlines
    pub fn escape_to_exit(mut self) -> Self {
        self.escape_to_exit = true;
        self
    }
}

impl Default for InlineInputOpts {
    fn default() -> Self {
        Self::new()
    }
}

pub struct InlinePrompt;

impl InlinePrompt {
    /// Ask user for text input with optional configuration
    pub fn input(message: &str, opts: Option<InlineInputOpts>) -> Result<String> {
        let opts = opts.unwrap_or_default();
        print!("{message} ");
        // Initialize input with placeholder text if provided
        let mut input = if let Some(ref placeholder) = opts.placeholder {
            placeholder.clone()
        } else {
            String::new()
        };

        let mut stdout = io::stdout();
        let terminal_width = crossterm::terminal::size()?.0 as usize;
        let prompt_len = message.len() + 1; // message + " "

        // Track current line and column position
        let mut current_col = prompt_len;

        enable_raw_mode()?;

        // Always try to enable enhanced keyboard protocol
        // Some terminals support it even if detection fails
        let enhancement_result = queue!(
            stdout,
            PushKeyboardEnhancementFlags(
                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
                    | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
            )
        );

        let supports_keyboard_enhancement = enhancement_result.is_ok();

        execute!(stdout, Show)?;

        // If we have initial text, display it and update cursor tracking
        if !input.is_empty() {
            for ch in input.chars() {
                if ch == '\n' {
                    execute!(stdout, Print("\n"), MoveToColumn(0))?;
                    current_col = 0;
                } else {
                    execute!(stdout, Print(ch))?;
                    current_col += 1;
                    if current_col >= terminal_width {
                        current_col = 0;
                    }
                }
            }
        }

        loop {
            if poll(Duration::from_millis(50))? {
                if let Event::Key(key) = event::read()? {
                    if key.kind == KeyEventKind::Press {
                        match key.code {
                            KeyCode::Enter => {
                                if opts.escape_to_exit {
                                    // In escape-to-exit mode: Enter always inserts newline
                                    input.push('\n');
                                    execute!(stdout, Print("\n"), MoveToColumn(0))?;
                                    current_col = 0;
                                } else if key.modifiers.contains(KeyModifiers::SHIFT) {
                                    // Normal mode: Shift+Enter inserts newline
                                    input.push('\n');
                                    execute!(stdout, Print("\n"), MoveToColumn(0))?;
                                    current_col = 0;
                                } else {
                                    // Normal mode: Regular Enter submits
                                    break;
                                }
                            }
                            KeyCode::Esc => {
                                if opts.escape_to_exit {
                                    // In escape-to-exit mode: Esc submits
                                    break;
                                } else {
                                    // Normal mode: Esc cancels (return empty string)
                                    input.clear();
                                    break;
                                }
                            }
                            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                                disable_raw_mode()?;
                                execute!(stdout, Show)?;
                                println!("\nExiting...");
                                std::process::exit(0);
                            }
                            KeyCode::Char(c) => {
                                input.push(c);
                                execute!(stdout, Print(c))?;
                            }
                            KeyCode::Backspace => {
                                if !input.is_empty() {
                                    let last_char = input.chars().last().unwrap();

                                    if last_char == '\n' {
                                        // Removing a newline - move cursor up and to end of previous line
                                        input.pop();
                                        execute!(stdout, MoveUp(1))?;

                                        // Calculate where to position cursor on the previous line
                                        let lines: Vec<&str> = input.split('\n').collect();
                                        if let Some(last_line) = lines.last() {
                                            // Only add prompt_len if we're on the first line
                                            let cursor_pos = if lines.len() == 1 {
                                                prompt_len + last_line.len()
                                            } else {
                                                last_line.len()
                                            };
                                            execute!(stdout, MoveToColumn(cursor_pos as u16))?;
                                        } else {
                                            // If no lines left, we're back at the prompt
                                            execute!(stdout, MoveToColumn(prompt_len as u16))?;
                                        }
                                    } else {
                                        // Regular character backspace
                                        input.pop();

                                        // For now, just use simple backspace
                                        // TODO: Handle line wrapping properly with newlines
                                        execute!(stdout, Print("\x08 \x08"))?;
                                    }
                                }
                            }
                            _ => {}
                        }
                    }
                }
            }
        }

        // Disable enhanced keyboard protocol if it was enabled
        if supports_keyboard_enhancement {
            queue!(stdout, PopKeyboardEnhancementFlags)?;
        }

        disable_raw_mode()?;
        println!();

        Ok(input)
    }

    /// Legacy method for backward compatibility
    pub fn input_with_placeholder(message: &str, placeholder: Option<&str>) -> Result<String> {
        let opts = placeholder.map(|p| InlineInputOpts::new().placeholder(p));
        Self::input(message, opts)
    }

    /// Ask user for yes/no confirmation
    pub fn confirm(message: &str, default: bool) -> Result<bool> {
        let options = if default {
            ["Yes", "No"]
        } else {
            ["No", "Yes"]
        };
        let selected = Self::select(message, &options)?;
        Ok(if default {
            selected == 0
        } else {
            selected == 1
        })
    }

    /// Ask user to select from a list of options
    pub fn select(message: &str, options: &[&str]) -> Result<usize> {
        println!("{message}");
        let mut selected = 0;
        let mut stdout = io::stdout();

        // Print all options initially
        for (i, option) in options.iter().enumerate() {
            if i == selected {
                println!("{option}");
            } else {
                println!("  {option}");
            }
        }

        enable_raw_mode()?;
        execute!(stdout, Hide)?;

        loop {
            if poll(Duration::from_millis(50))? {
                if let Event::Key(key) = event::read()? {
                    if key.kind == KeyEventKind::Press {
                        match key.code {
                            KeyCode::Up => {
                                selected = if selected > 0 {
                                    selected - 1
                                } else {
                                    options.len() - 1
                                };
                                Self::redraw_options(&mut stdout, options, selected)?;
                            }
                            KeyCode::Down => {
                                selected = (selected + 1) % options.len();
                                Self::redraw_options(&mut stdout, options, selected)?;
                            }
                            KeyCode::Enter => break,
                            KeyCode::Char('c')
                                if key
                                    .modifiers
                                    .contains(crossterm::event::KeyModifiers::CONTROL) =>
                            {
                                disable_raw_mode()?;
                                execute!(stdout, Show)?;
                                println!("\nExiting...");
                                std::process::exit(0);
                            }
                            _ => {}
                        }
                    }
                }
            }
        }

        disable_raw_mode()?;
        execute!(stdout, Show)?;
        Ok(selected)
    }

    fn redraw_options(stdout: &mut io::Stdout, options: &[&str], selected: usize) -> Result<()> {
        // Move cursor up to the first option and to column 0
        execute!(stdout, MoveUp(options.len() as u16), MoveToColumn(0))?;

        // Redraw all options
        for (i, option) in options.iter().enumerate() {
            execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
            if i == selected {
                execute!(
                    stdout,
                    SetForegroundColor(Color::Cyan),
                    Print(format!("{option}")),
                    ResetColor
                )?;
            } else {
                execute!(stdout, Print(format!("  {option}")))?;
            }

            // Move to next line (except for the last option)
            if i < options.len() - 1 {
                execute!(stdout, Print("\n"))?;
            }
        }

        // Ensure we end up at the correct position
        execute!(stdout, Print("\n"))?;

        Ok(())
    }
}