chant/
choose.rs

1//! Selection prompt with beautiful Charm-style rendering.
2//! 
3//! Uses buffered rendering to prevent flickering.
4
5use crossterm::{
6    cursor,
7    event::{self, Event, KeyCode, KeyModifiers},
8    execute, queue,
9    terminal::{self, ClearType},
10};
11use glyphs::{style, gradient, Color};
12use std::io::{stdout, Write};
13
14// Beautiful colors inspired by Charm
15const ACCENT: Color = Color::Rgb { r: 212, g: 92, b: 235 };     // Purple/Pink
16const ACCENT_DIM: Color = Color::Rgb { r: 147, g: 112, b: 219 }; // Softer purple
17const CURSOR_COLOR: Color = Color::Rgb { r: 255, g: 135, b: 175 }; // Pink
18const DIM: Color = Color::Rgb { r: 102, g: 102, b: 102 };         // Gray
19
20/// Create a selection prompt.
21pub fn choose<S: Into<String>>(options: &[S]) -> Choose
22where
23    S: Clone,
24{
25    Choose::new(options.iter().map(|s| s.clone().into()).collect())
26}
27
28/// Selection prompt builder.
29pub struct Choose {
30    options: Vec<String>,
31    header: Option<String>,
32    cursor: String,
33    selected: usize,
34    limit: usize,
35    show_help: bool,
36}
37
38impl Choose {
39    /// Create a new selection.
40    pub fn new(options: Vec<String>) -> Self {
41        Self {
42            options,
43            header: None,
44            cursor: "❯".to_string(),
45            selected: 0,
46            limit: 0,
47            show_help: true,
48        }
49    }
50
51    /// Set header text.
52    #[must_use]
53    pub fn header(mut self, header: impl Into<String>) -> Self {
54        self.header = Some(header.into());
55        self
56    }
57
58    /// Set cursor string.
59    #[must_use]
60    pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
61        self.cursor = cursor.into();
62        self
63    }
64
65    /// Limit visible items.
66    #[must_use]
67    pub fn limit(mut self, limit: usize) -> Self {
68        self.limit = limit;
69        self
70    }
71
72    /// Hide the help text.
73    #[must_use]
74    pub fn no_help(mut self) -> Self {
75        self.show_help = false;
76        self
77    }
78
79    /// Build the display buffer (no flickering)
80    fn render(&self, offset: usize, visible: usize) -> String {
81        let mut buffer = String::new();
82        
83        // Header with gradient
84        if let Some(ref header) = self.header {
85            buffer.push_str(&format!("{}\r\n", gradient(header, ACCENT, ACCENT_DIM)));
86        }
87
88        // Options with beautiful styling
89        for (i, option) in self.options.iter().enumerate().skip(offset).take(visible) {
90            let is_selected = i == self.selected;
91
92            if is_selected {
93                // Selected item: cursor + highlighted text
94                let cursor_styled = style(&self.cursor).fg(CURSOR_COLOR).bold();
95                let text_styled = style(option).fg(Color::White).bold();
96                buffer.push_str(&format!(" {} {}\r\n", cursor_styled, text_styled));
97            } else {
98                // Unselected items: dimmed based on distance
99                let distance = if i > self.selected { i - self.selected } else { self.selected - i };
100                let alpha = match distance {
101                    1 => Color::Rgb { r: 180, g: 180, b: 180 },
102                    2 => Color::Rgb { r: 140, g: 140, b: 140 },
103                    _ => DIM,
104                };
105                buffer.push_str(&format!("   {}\r\n", style(option).fg(alpha)));
106            }
107        }
108
109        // Help text
110        if self.show_help {
111            buffer.push_str(&format!(
112                "{}\r\n",
113                style("↑/↓ navigate • enter select • esc cancel").fg(DIM).dim()
114            ));
115        }
116
117        buffer
118    }
119
120    /// Run the selection.
121    pub fn run(mut self) -> Option<String> {
122        if self.options.is_empty() {
123            return None;
124        }
125
126        let mut stdout = stdout();
127
128        // Enable raw mode and hide cursor to prevent flickering
129        terminal::enable_raw_mode().expect("Failed to enable raw mode");
130        execute!(stdout, cursor::Hide).ok();
131
132        // Calculate visible range
133        let visible = if self.limit > 0 {
134            self.limit.min(self.options.len())
135        } else {
136            self.options.len()
137        };
138
139        let mut offset = 0;
140        let has_header = self.header.is_some();
141        let help_lines = if self.show_help { 1 } else { 0 };
142        let total_lines = visible + if has_header { 1 } else { 0 } + help_lines;
143
144        // Initial render
145        let buffer = self.render(offset, visible);
146        write!(stdout, "{}", buffer).ok();
147        stdout.flush().ok();
148
149        loop {
150            // Handle input
151            if let Ok(Event::Key(key)) = event::read() {
152                match key.code {
153                    KeyCode::Enter => break,
154                    KeyCode::Esc => {
155                        // Clean up and exit
156                        execute!(stdout, cursor::Show).ok();
157                        terminal::disable_raw_mode().expect("Failed to disable raw mode");
158                        
159                        // Clear the menu
160                        queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
161                        for _ in 0..total_lines {
162                            queue!(stdout, cursor::MoveToColumn(0), terminal::Clear(ClearType::CurrentLine), cursor::MoveDown(1)).ok();
163                        }
164                        queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
165                        stdout.flush().ok();
166                        return None;
167                    }
168                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
169                        execute!(stdout, cursor::Show).ok();
170                        terminal::disable_raw_mode().expect("Failed to disable raw mode");
171                        return None;
172                    }
173                    KeyCode::Up | KeyCode::Char('k') => {
174                        if self.selected > 0 {
175                            self.selected -= 1;
176                            if self.selected < offset {
177                                offset = self.selected;
178                            }
179                        }
180                    }
181                    KeyCode::Down | KeyCode::Char('j') => {
182                        if self.selected < self.options.len() - 1 {
183                            self.selected += 1;
184                            if self.selected >= offset + visible {
185                                offset = self.selected - visible + 1;
186                            }
187                        }
188                    }
189                    KeyCode::Home | KeyCode::Char('g') => {
190                        self.selected = 0;
191                        offset = 0;
192                    }
193                    KeyCode::End | KeyCode::Char('G') => {
194                        self.selected = self.options.len() - 1;
195                        offset = self.options.len().saturating_sub(visible);
196                    }
197                    _ => continue, // Don't re-render for unknown keys
198                }
199
200                // Move cursor back up and re-render
201                queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
202                
203                // Clear all lines first (batch operation)
204                for _ in 0..total_lines {
205                    queue!(stdout, cursor::MoveToColumn(0), terminal::Clear(ClearType::CurrentLine), cursor::MoveDown(1)).ok();
206                }
207                queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
208                
209                // Write new content
210                let buffer = self.render(offset, visible);
211                write!(stdout, "{}", buffer).ok();
212                stdout.flush().ok();
213            }
214        }
215
216        // Show cursor and disable raw mode
217        execute!(stdout, cursor::Show).ok();
218        terminal::disable_raw_mode().expect("Failed to disable raw mode");
219        
220        // Clear menu and show final selection
221        queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
222        for _ in 0..total_lines {
223            queue!(stdout, cursor::MoveToColumn(0), terminal::Clear(ClearType::CurrentLine), cursor::MoveDown(1)).ok();
224        }
225        queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
226        stdout.flush().ok();
227        
228        let selected_text = &self.options[self.selected];
229        println!(
230            " {} {}",
231            style("✓").fg(Color::Green).bold(),
232            style(selected_text).fg(Color::White).bold()
233        );
234
235        Some(selected_text.clone())
236    }
237}