1use 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
14const ACCENT: Color = Color::Rgb { r: 212, g: 92, b: 235 }; const ACCENT_DIM: Color = Color::Rgb { r: 147, g: 112, b: 219 }; const CURSOR_COLOR: Color = Color::Rgb { r: 255, g: 135, b: 175 }; const DIM: Color = Color::Rgb { r: 102, g: 102, b: 102 }; pub 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
28pub 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 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 #[must_use]
53 pub fn header(mut self, header: impl Into<String>) -> Self {
54 self.header = Some(header.into());
55 self
56 }
57
58 #[must_use]
60 pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
61 self.cursor = cursor.into();
62 self
63 }
64
65 #[must_use]
67 pub fn limit(mut self, limit: usize) -> Self {
68 self.limit = limit;
69 self
70 }
71
72 #[must_use]
74 pub fn no_help(mut self) -> Self {
75 self.show_help = false;
76 self
77 }
78
79 fn render(&self, offset: usize, visible: usize) -> String {
81 let mut buffer = String::new();
82
83 if let Some(ref header) = self.header {
85 buffer.push_str(&format!("{}\r\n", gradient(header, ACCENT, ACCENT_DIM)));
86 }
87
88 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 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 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 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 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 terminal::enable_raw_mode().expect("Failed to enable raw mode");
130 execute!(stdout, cursor::Hide).ok();
131
132 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 let buffer = self.render(offset, visible);
146 write!(stdout, "{}", buffer).ok();
147 stdout.flush().ok();
148
149 loop {
150 if let Ok(Event::Key(key)) = event::read() {
152 match key.code {
153 KeyCode::Enter => break,
154 KeyCode::Esc => {
155 execute!(stdout, cursor::Show).ok();
157 terminal::disable_raw_mode().expect("Failed to disable raw mode");
158
159 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, }
199
200 queue!(stdout, cursor::MoveUp(total_lines as u16)).ok();
202
203 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 let buffer = self.render(offset, visible);
211 write!(stdout, "{}", buffer).ok();
212 stdout.flush().ok();
213 }
214 }
215
216 execute!(stdout, cursor::Show).ok();
218 terminal::disable_raw_mode().expect("Failed to disable raw mode");
219
220 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}