chant 0.1.1

Shell glamour - beautiful prompts and output for scripts 🪄
Documentation
//! Selection prompt with beautiful Charm-style rendering.
//! 
//! Uses buffered rendering to prevent flickering.

use crossterm::{
    cursor,
    event::{self, Event, KeyCode, KeyModifiers},
    execute, queue,
    terminal::{self, ClearType},
};
use glyphs::{style, gradient, Color};
use std::io::{stdout, Write};

// Beautiful colors inspired by Charm
const ACCENT: Color = Color::Rgb { r: 212, g: 92, b: 235 };     // Purple/Pink
const ACCENT_DIM: Color = Color::Rgb { r: 147, g: 112, b: 219 }; // Softer purple
const CURSOR_COLOR: Color = Color::Rgb { r: 255, g: 135, b: 175 }; // Pink
const DIM: Color = Color::Rgb { r: 102, g: 102, b: 102 };         // Gray

/// Create a selection prompt.
pub fn choose<S: Into<String>>(options: &[S]) -> Choose
where
    S: Clone,
{
    Choose::new(options.iter().map(|s| s.clone().into()).collect())
}

/// Selection prompt builder.
pub struct Choose {
    options: Vec<String>,
    header: Option<String>,
    cursor: String,
    selected: usize,
    limit: usize,
    show_help: bool,
}

impl Choose {
    /// Create a new selection.
    pub fn new(options: Vec<String>) -> Self {
        Self {
            options,
            header: None,
            cursor: "".to_string(),
            selected: 0,
            limit: 0,
            show_help: true,
        }
    }

    /// Set header text.
    #[must_use]
    pub fn header(mut self, header: impl Into<String>) -> Self {
        self.header = Some(header.into());
        self
    }

    /// Set cursor string.
    #[must_use]
    pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
        self.cursor = cursor.into();
        self
    }

    /// Limit visible items.
    #[must_use]
    pub fn limit(mut self, limit: usize) -> Self {
        self.limit = limit;
        self
    }

    /// Hide the help text.
    #[must_use]
    pub fn no_help(mut self) -> Self {
        self.show_help = false;
        self
    }

    /// Build the display buffer (no flickering)
    fn render(&self, offset: usize, visible: usize) -> String {
        let mut buffer = String::new();
        
        // Header with gradient
        if let Some(ref header) = self.header {
            buffer.push_str(&format!("{}\r\n", gradient(header, ACCENT, ACCENT_DIM)));
        }

        // Options with beautiful styling
        for (i, option) in self.options.iter().enumerate().skip(offset).take(visible) {
            let is_selected = i == self.selected;

            if is_selected {
                // Selected item: cursor + highlighted text
                let cursor_styled = style(&self.cursor).fg(CURSOR_COLOR).bold();
                let text_styled = style(option).fg(Color::White).bold();
                buffer.push_str(&format!(" {} {}\r\n", cursor_styled, text_styled));
            } else {
                // Unselected items: dimmed based on distance
                let distance = if i > self.selected { i - self.selected } else { self.selected - i };
                let alpha = match distance {
                    1 => Color::Rgb { r: 180, g: 180, b: 180 },
                    2 => Color::Rgb { r: 140, g: 140, b: 140 },
                    _ => DIM,
                };
                buffer.push_str(&format!("   {}\r\n", style(option).fg(alpha)));
            }
        }

        // Help text
        if self.show_help {
            buffer.push_str(&format!(
                "{}\r\n",
                style("↑/↓ navigate • enter select • esc cancel").fg(DIM).dim()
            ));
        }

        buffer
    }

    /// Run the selection.
    pub fn run(mut self) -> Option<String> {
        if self.options.is_empty() {
            return None;
        }

        let mut stdout = stdout();

        // Enable raw mode and hide cursor to prevent flickering
        terminal::enable_raw_mode().expect("Failed to enable raw mode");
        execute!(stdout, cursor::Hide).ok();

        // Calculate visible range
        let visible = if self.limit > 0 {
            self.limit.min(self.options.len())
        } else {
            self.options.len()
        };

        let mut offset = 0;
        let has_header = self.header.is_some();
        let help_lines = if self.show_help { 1 } else { 0 };
        let total_lines = visible + if has_header { 1 } else { 0 } + help_lines;

        // Initial render
        let buffer = self.render(offset, visible);
        write!(stdout, "{}", buffer).ok();
        stdout.flush().ok();

        loop {
            // Handle input
            if let Ok(Event::Key(key)) = event::read() {
                match key.code {
                    KeyCode::Enter => break,
                    KeyCode::Esc => {
                        // Clean up and exit
                        execute!(stdout, cursor::Show).ok();
                        terminal::disable_raw_mode().expect("Failed to disable raw mode");
                        
                        // Clear the menu
                        queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
                        for _ in 0..total_lines {
                            queue!(stdout, cursor::MoveToColumn(0), terminal::Clear(ClearType::CurrentLine), cursor::MoveDown(1)).ok();
                        }
                        queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
                        stdout.flush().ok();
                        return None;
                    }
                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        execute!(stdout, cursor::Show).ok();
                        terminal::disable_raw_mode().expect("Failed to disable raw mode");
                        return None;
                    }
                    KeyCode::Up | KeyCode::Char('k') => {
                        if self.selected > 0 {
                            self.selected -= 1;
                            if self.selected < offset {
                                offset = self.selected;
                            }
                        }
                    }
                    KeyCode::Down | KeyCode::Char('j') => {
                        if self.selected < self.options.len() - 1 {
                            self.selected += 1;
                            if self.selected >= offset + visible {
                                offset = self.selected - visible + 1;
                            }
                        }
                    }
                    KeyCode::Home | KeyCode::Char('g') => {
                        self.selected = 0;
                        offset = 0;
                    }
                    KeyCode::End | KeyCode::Char('G') => {
                        self.selected = self.options.len() - 1;
                        offset = self.options.len().saturating_sub(visible);
                    }
                    _ => continue, // Don't re-render for unknown keys
                }

                // Move cursor back up and re-render
                queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
                
                // Clear all lines first (batch operation)
                for _ in 0..total_lines {
                    queue!(stdout, cursor::MoveToColumn(0), terminal::Clear(ClearType::CurrentLine), cursor::MoveDown(1)).ok();
                }
                queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
                
                // Write new content
                let buffer = self.render(offset, visible);
                write!(stdout, "{}", buffer).ok();
                stdout.flush().ok();
            }
        }

        // Show cursor and disable raw mode
        execute!(stdout, cursor::Show).ok();
        terminal::disable_raw_mode().expect("Failed to disable raw mode");
        
        // Clear menu and show final selection
        queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
        for _ in 0..total_lines {
            queue!(stdout, cursor::MoveToColumn(0), terminal::Clear(ClearType::CurrentLine), cursor::MoveDown(1)).ok();
        }
        queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
        stdout.flush().ok();
        
        let selected_text = &self.options[self.selected];
        println!(
            " {} {}",
            style("").fg(Color::Green).bold(),
            style(selected_text).fg(Color::White).bold()
        );

        Some(selected_text.clone())
    }
}