cactui 0.1.0

Terminal-based interactive prompts and key menus for CLI applications
Documentation
use anyhow::Result;

/// Calculate how many terminal lines a text string will occupy when displayed
pub fn calculate_text_lines(text: &str) -> Result<usize> {
    let terminal_width = crossterm::terminal::size()
        .map_err(|e| anyhow::anyhow!("Failed to get terminal size: {}", e))?
        .0 as usize;
    Ok(calculate_text_lines_with_width(text, terminal_width))
}

/// Calculate how many terminal lines a text string will occupy with a given terminal width
pub fn calculate_text_lines_with_width(text: &str, terminal_width: usize) -> usize {
    if text.is_empty() || terminal_width == 0 {
        return 1;
    }

    let mut total_lines = 0;

    // Split on newlines first
    for line in text.split('\n') {
        if line.is_empty() {
            total_lines += 1;
        } else {
            // Calculate how many lines this line takes when wrapped
            let line_len = line.len();
            let lines_for_this_line = (line_len + terminal_width - 1) / terminal_width; // Ceiling division
            total_lines += lines_for_this_line;
        }
    }

    if total_lines == 0 { 1 } else { total_lines }
}

/// Split text into lines based on newlines and terminal width
pub fn split_text_into_lines(text: &str) -> Result<Vec<String>> {
    let terminal_width = crossterm::terminal::size()
        .map_err(|e| anyhow::anyhow!("Failed to get terminal size: {}", e))?
        .0 as usize;
    Ok(split_text_into_lines_with_width(text, terminal_width))
}

/// Split text into lines based on newlines and given terminal width
pub fn split_text_into_lines_with_width(text: &str, terminal_width: usize) -> Vec<String> {
    if text.is_empty() || terminal_width == 0 {
        return vec![String::new()];
    }

    let mut result = Vec::new();

    // Split on newlines first
    for line in text.split('\n') {
        if line.is_empty() {
            result.push(String::new());
        } else {
            // Split this line into chunks of terminal_width characters (not bytes!)
            let chars: Vec<char> = line.chars().collect();
            let mut start = 0;

            while start < chars.len() {
                let end = std::cmp::min(start + terminal_width, chars.len());
                let chunk: String = chars[start..end].iter().collect();
                result.push(chunk);
                start = end;
            }
        }
    }

    if result.is_empty() {
        vec![String::new()]
    } else {
        result
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_simple_cases() {
        // Empty text
        assert_eq!(calculate_text_lines_with_width("", 80), 1);

        // Short text that fits on one line
        assert_eq!(calculate_text_lines_with_width("hello", 80), 1);
        assert_eq!(calculate_text_lines_with_width("hello world", 80), 1);
    }

    #[test]
    fn test_exact_width_boundaries() {
        // Text exactly fits terminal width
        assert_eq!(calculate_text_lines_with_width("1234567890", 10), 1);

        // Text is one character over
        assert_eq!(calculate_text_lines_with_width("12345678901", 10), 2);

        // Text is exactly double the width
        assert_eq!(
            calculate_text_lines_with_width("12345678901234567890", 10),
            2
        );

        // Text is one character over double
        assert_eq!(
            calculate_text_lines_with_width("123456789012345678901", 10),
            3
        );
    }

    #[test]
    fn test_newlines() {
        // Single newline
        assert_eq!(calculate_text_lines_with_width("line1\nline2", 80), 2);

        // Multiple newlines
        assert_eq!(
            calculate_text_lines_with_width("line1\nline2\nline3", 80),
            3
        );

        // Empty lines
        assert_eq!(calculate_text_lines_with_width("line1\n\nline3", 80), 3);

        // Newlines with wrapping
        assert_eq!(
            calculate_text_lines_with_width("12345678901\n12345678901", 10),
            4
        ); // Each line wraps to 2
    }

    #[test]
    fn test_description_case() {
        // The specific case from the user
        let desc = "Description: sad;lkfjsad;ljfsal;djkf;laskdjf;lkasdj;flkjasdl;kfjasd;lkjfas;lkdjf;lksajdfl;jkasd;lkf";

        // Let's say terminal width is 80
        let width = 80;
        let result = calculate_text_lines_with_width(desc, width);

        // Description length: 13 + 1 + 86 = 100 characters
        // 100 / 80 = 1.25, so ceiling = 2 lines
        assert_eq!(result, 2);

        // Test with different widths
        assert_eq!(calculate_text_lines_with_width(desc, 50), 2); // 100/50 = 2
        assert_eq!(calculate_text_lines_with_width(desc, 40), 3); // 100/40 = 2.5 -> 3
        assert_eq!(calculate_text_lines_with_width(desc, 100), 1); // 100/100 = 1
    }

    #[test]
    fn test_zero_width() {
        assert_eq!(calculate_text_lines_with_width("hello", 0), 1);
    }

    #[test]
    fn test_realistic_scenarios() {
        // Short description
        assert_eq!(
            calculate_text_lines_with_width("Description: User clicks button", 80),
            1
        );

        // Long description that wraps
        let long_desc = format!("Description: {}", "a".repeat(70));
        assert_eq!(calculate_text_lines_with_width(&long_desc, 80), 2); // 13 + 70 = 83 chars, 83/80 = 2

        // Very long description
        let very_long = format!("Description: {}", "a".repeat(200));
        assert_eq!(calculate_text_lines_with_width(&very_long, 80), 3); // 13 + 200 = 213 chars, 213/80 = 2.66 -> 3
    }

    #[test]
    fn test_split_text_simple() {
        // Empty text
        assert_eq!(split_text_into_lines_with_width("", 10), vec![""]);

        // Short text that fits
        assert_eq!(split_text_into_lines_with_width("hello", 10), vec!["hello"]);

        // Text exactly at width
        assert_eq!(
            split_text_into_lines_with_width("1234567890", 10),
            vec!["1234567890"]
        );

        // Text that needs wrapping
        assert_eq!(
            split_text_into_lines_with_width("12345678901", 10),
            vec!["1234567890", "1"]
        );
    }

    #[test]
    fn test_split_text_with_newlines() {
        // Single newline
        assert_eq!(
            split_text_into_lines_with_width("line1\nline2", 10),
            vec!["line1", "line2"]
        );

        // Multiple newlines
        assert_eq!(
            split_text_into_lines_with_width("a\nb\nc", 10),
            vec!["a", "b", "c"]
        );

        // Empty lines
        assert_eq!(
            split_text_into_lines_with_width("a\n\nb", 10),
            vec!["a", "", "b"]
        );

        // Newlines with wrapping
        assert_eq!(
            split_text_into_lines_with_width("12345678901\n12345678901", 10),
            vec!["1234567890", "1", "1234567890", "1"]
        );
    }

    #[test]
    fn test_split_text_description_case() {
        let desc = "Description: hello world test";
        let result = split_text_into_lines_with_width(desc, 20);

        // "Description: hello w" (20 chars) + "orld test" (9 chars)
        assert_eq!(result, vec!["Description: hello w", "orld test"]);

        // Test with exact boundary
        let exact = "12345678901234567890"; // exactly 20 chars
        assert_eq!(
            split_text_into_lines_with_width(exact, 20),
            vec!["12345678901234567890"]
        );

        // Test with one over
        let over = "123456789012345678901"; // 21 chars
        assert_eq!(
            split_text_into_lines_with_width(over, 20),
            vec!["12345678901234567890", "1"]
        );
    }

    #[test]
    fn test_split_text_zero_width() {
        assert_eq!(split_text_into_lines_with_width("hello", 0), vec![""]);
    }

    #[test]
    fn test_split_text_unicode() {
        // Unicode box drawing characters - "┌────────────┐" is 14 characters
        let box_line = "┌────────────┐";
        let result = split_text_into_lines_with_width(box_line, 10);
        // Should split as: "┌─────────" (10 chars) + "───┐" (4 chars)
        assert_eq!(result, vec!["┌─────────", "───┐"]);

        // Mixed ASCII and Unicode
        let mixed = "Hello ─── World"; // 15 characters
        let result = split_text_into_lines_with_width(mixed, 8);
        assert_eq!(result, vec!["Hello ──", "─ World"]);

        // Emoji
        let emoji = "Hello 🌟 World 🚀"; // Each emoji is 1 character
        let result = split_text_into_lines_with_width(emoji, 10);
        assert_eq!(result, vec!["Hello 🌟 Wo", "rld 🚀"]);

        // The specific failing case
        let failing_line = "19│└────────────────────────────┘";
        let result = split_text_into_lines_with_width(failing_line, 84);
        // Should not panic and should handle the Unicode box characters correctly
        assert!(result.len() >= 1);

        // Test that we don't panic on Unicode boundaries
        let unicode_heavy = "🌟🚀🎉🔥💯⭐🌈🎯";
        let result = split_text_into_lines_with_width(unicode_heavy, 3);
        assert_eq!(result, vec!["🌟🚀🎉", "🔥💯⭐", "🌈🎯"]);
    }
}