cactui 0.1.0

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

/// Action that can be taken when a menu item is selected
pub enum MenuAction<T> {
    /// Execute a callback function with current context
    Callback {
        callback: Box<dyn Fn(&mut T, &mut KeyMenuLineCounts) -> Result<MenuResult>>,
        clear_menu: bool,
    },
    /// Navigate to a nested submenu
    Submenu(KeyMenuConfig<T>),
    /// Exit current menu level
    Exit,
    ExitKeepHeader,
}

/// Result of executing a menu action
#[derive(Debug, Clone)]
pub enum MenuResult {
    /// Stay in current menu
    Stay,
    /// Exit current menu level
    Exit,
    /// Exit current menu level
    ExitKeepHeader,
}

/// Configuration for a single menu item
pub struct KeyMenuItem<T> {
    pub key: KeyCode,
    pub description: String,
    pub color: Option<Color>,
    pub action: MenuAction<T>,
}

/// Configuration for an entire key menu
pub struct KeyMenuConfig<T> {
    /// Optional header lines to display above menu (e.g., "Description: foo", "Channels: RGB")
    pub header_lines: Option<Box<dyn Fn(&T) -> Vec<String>>>,
    /// Menu items
    pub items: Vec<KeyMenuItem<T>>,
    /// Whether this menu should loop (stay open after actions) or exit after one action
    pub should_loop: bool,
}

pub struct KeyMenuLineCounts {
    pub menu: usize,
    pub header: usize,
}

impl KeyMenuLineCounts {
    fn total(&self) -> usize {
        self.menu + self.header
    }
}
/// Clear the specified number of lines from the terminal
pub fn clear_lines(line_count: usize) -> Result<()> {
    if line_count == 0 {
        return Ok(());
    }

    execute!(io::stdout(), MoveUp(line_count as u16), MoveToColumn(0))?;
    for _ in 0..line_count {
        execute!(io::stdout(), Clear(ClearType::CurrentLine))?;
        println!();
    }
    execute!(io::stdout(), MoveUp(line_count as u16), MoveToColumn(0))?;

    Ok(())
}

impl<T> KeyMenuConfig<T> {
    /// Run a key menu with the given configuration and context
    pub fn run_menu(&self, context: &mut T) -> Result<MenuResult> {
        // let mut total_lines = 0;

        let mut line_counts = KeyMenuLineCounts { menu: 0, header: 0 };

        loop {
            // Always clear previous menu content
            if line_counts.total() > 0 {
                clear_lines(line_counts.total())?;
            }
            disable_raw_mode()?;
            execute!(io::stdout(), Show)?;

            // Display header lines if configured
            line_counts.header = if let Some(header_fn) = &self.header_lines {
                let header_lines = header_fn(context);
                let mut line_count = 0;
                for header_line in &header_lines {
                    let lines = crate::split_text_into_lines(&header_line)?;
                    for line in &lines {
                        println!("{line}",);
                    }
                    line_count += lines.len();
                }
                line_count
            } else {
                0
            };

            // Display menu items
            let items: Vec<_> = self.items.iter().collect();

            line_counts.menu = self.display_menu_items()?;

            io::stdout().flush()?;

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

            let item = loop {
                if poll(Duration::from_millis(50))? {
                    if let Event::Key(key) = event::read()? {
                        if key.kind == KeyEventKind::Press {
                            match key.code {
                                KeyCode::Char('c')
                                    if key
                                        .modifiers
                                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                                {
                                    disable_raw_mode()?;
                                    execute!(io::stdout(), Show)?;
                                    println!("\nExiting...");
                                    std::process::exit(0);
                                }
                                _ => {
                                    // Check menu items
                                    if let Some(item) =
                                        items.iter().find(|item| item.key == key.code)
                                    {
                                        break Some(item);
                                    }

                                    continue; // Unknown key, keep listening
                                }
                            };
                        }
                    }
                }
            };

            let choice = if let Some(item) = item {
                match &item.action {
                    MenuAction::Callback {
                        callback,
                        clear_menu,
                    } => {
                        if *clear_menu {
                            clear_lines(line_counts.menu)?;
                            line_counts.menu = 0;
                        }
                        callback(context, &mut line_counts)
                    }
                    MenuAction::Submenu(submenu_config) => {
                        // Clear the current menu completely before showing submenu
                        clear_lines(line_counts.total())?;

                        line_counts.menu = 0;
                        line_counts.header = 0;

                        // Disable raw mode before running submenu so header printing works
                        disable_raw_mode()?;
                        execute!(io::stdout(), Show)?;

                        // Run the submenu
                        let result = Self::run_menu(submenu_config, context)?;

                        match result {
                            MenuResult::Exit => Ok(MenuResult::Stay),
                            other => Ok(other),
                        }
                    }
                    MenuAction::Exit => Ok(MenuResult::Exit),
                    MenuAction::ExitKeepHeader => Ok(MenuResult::ExitKeepHeader),
                }
            } else {
                Ok(MenuResult::Exit)
            };

            disable_raw_mode()?;
            execute!(io::stdout(), Show)?;

            io::stdout().flush()?;

            match choice? {
                MenuResult::Stay => {
                    if !self.should_loop {
                        return Ok(MenuResult::Exit);
                    }
                }
                MenuResult::Exit => {
                    clear_lines(line_counts.total())?;
                    return Ok(MenuResult::Exit);
                }
                MenuResult::ExitKeepHeader => {
                    clear_lines(line_counts.menu)?;
                    return Ok(MenuResult::Exit);
                }
            }
        }
    }

    /// Display menu items and return the number of lines used
    fn display_menu_items(&self) -> Result<usize> {
        if self.items.is_empty() {
            return Ok(0);
        }

        let terminal_width = crossterm::terminal::size().unwrap_or((80, 24)).0 as usize;

        // Check if any description contains newlines or would cause wrapping
        let has_multiline_descriptions = self.items.iter().any(|item| {
            item.description.contains('\n')
                || (item.key.to_string().len() + item.description.len() + 7) > terminal_width // "key   description"
        });

        // If any description is multi-line or would wrap, force vertical layout
        if has_multiline_descriptions {
            return self.display_vertically();
        }

        // Check if we can display horizontally (all items fit on one line)
        let total_width: usize = self
            .items
            .iter()
            .map(|item| item.key.to_string().len() + item.description.len() + 4) // "key   description    "
            .sum();

        if total_width < terminal_width {
            // Display horizontally
            for (i, item) in self.items.iter().enumerate() {
                let color = item.color.unwrap_or(Color::Cyan);
                execute!(
                    io::stdout(),
                    SetForegroundColor(color),
                    Print(&item.key),
                    ResetColor,
                    Print(format!("   {}", item.description))
                )?;

                if i < self.items.len() - 1 {
                    print!("    ");
                }
            }
            println!();
            Ok(1) // One line used
        } else {
            // Display vertically
            self.display_vertically()
        }
    }

    /// Display menu items vertically, handling multi-line descriptions properly
    fn display_vertically(&self) -> Result<usize> {
        let mut total_lines = 0;
        let terminal_width = crossterm::terminal::size().unwrap_or((80, 24)).0 as usize;

        for item in &self.items {
            let color = item.color.unwrap_or(Color::Cyan);
            let key_str = item.key.to_string();
            let prefix = format!("{}   ", key_str);
            let prefix_len = prefix.len();

            // Handle multi-line descriptions
            if item.description.contains('\n') {
                let lines: Vec<&str> = item.description.split('\n').collect();
                for (line_idx, line) in lines.iter().enumerate() {
                    if line_idx == 0 {
                        // First line: show key + description
                        execute!(
                            io::stdout(),
                            SetForegroundColor(color),
                            Print(&key_str),
                            ResetColor,
                            Print(format!("   {}", line))
                        )?;
                    } else {
                        // Subsequent lines: indent to align with description
                        execute!(
                            io::stdout(),
                            Print(format!("{}{}", " ".repeat(prefix_len), line))
                        )?;
                    }
                    println!();
                    total_lines += 1;
                }
            } else {
                // Single line description - check if it needs wrapping
                let full_line = format!("{}{}", prefix, item.description);
                if full_line.len() <= terminal_width {
                    // Fits on one line
                    execute!(
                        io::stdout(),
                        SetForegroundColor(color),
                        Print(&key_str),
                        ResetColor,
                        Print(format!("   {}", item.description))
                    )?;
                    println!();
                    total_lines += 1;
                } else {
                    // Needs wrapping
                    let available_width = terminal_width.saturating_sub(prefix_len);
                    if available_width < 10 {
                        // Not enough space for reasonable wrapping, just print as-is
                        execute!(
                            io::stdout(),
                            SetForegroundColor(color),
                            Print(&key_str),
                            ResetColor,
                            Print(format!("   {}", item.description))
                        )?;
                        println!();
                        total_lines += 1;
                    } else {
                        // Wrap the description
                        let wrapped_lines = Self::wrap_text(&item.description, available_width);
                        for (line_idx, line) in wrapped_lines.iter().enumerate() {
                            if line_idx == 0 {
                                // First line: show key + description
                                execute!(
                                    io::stdout(),
                                    SetForegroundColor(color),
                                    Print(&key_str),
                                    ResetColor,
                                    Print(format!("   {}", line))
                                )?;
                            } else {
                                // Subsequent lines: indent to align with description
                                execute!(
                                    io::stdout(),
                                    Print(format!("{}{}", " ".repeat(prefix_len), line))
                                )?;
                            }
                            println!();
                            total_lines += 1;
                        }
                    }
                }
            }
        }
        Ok(total_lines)
    }

    /// Wrap text to fit within the specified width, breaking at word boundaries when possible
    fn wrap_text(text: &str, width: usize) -> Vec<String> {
        if width == 0 {
            return vec![text.to_string()];
        }

        let mut lines = Vec::new();
        let mut current_line = String::new();

        for word in text.split_whitespace() {
            // If adding this word would exceed the width
            if !current_line.is_empty() && current_line.len() + 1 + word.len() > width {
                // Start a new line
                lines.push(current_line);
                current_line = word.to_string();
            } else {
                // Add word to current line
                if !current_line.is_empty() {
                    current_line.push(' ');
                }
                current_line.push_str(word);
            }
        }

        // Add the last line if it's not empty
        if !current_line.is_empty() {
            lines.push(current_line);
        }

        // If no lines were created, return the original text
        if lines.is_empty() {
            lines.push(text.to_string());
        }

        lines
    }
}