1use crate::styles::{paint, DIM, OK, WHITE};
4
5use super::core::{Frame, Key, Prompt, RenderCtx, Step};
6use super::error::{PromptError, Result};
7use super::theme;
8
9#[derive(Clone)]
10struct AcOption {
11 value: String,
12 label: String,
13 hint: Option<String>,
14}
15
16#[derive(Debug, Clone)]
18pub struct AutocompleteSelected {
19 value: String,
20 label: String,
21}
22impl AutocompleteSelected {
23 pub fn value(&self) -> &str {
25 &self.value
26 }
27 pub fn label(&self) -> &str {
29 &self.label
30 }
31}
32
33pub struct AutocompletePrompt {
35 label: String,
36 options: Vec<AcOption>,
37 placeholder: Option<String>,
38 max_items: usize,
39 query: String,
40 cur: usize,
41}
42
43pub fn autocomplete(label: impl Into<String>) -> AutocompletePrompt {
56 AutocompletePrompt {
57 label: label.into(),
58 options: Vec::new(),
59 placeholder: None,
60 max_items: 8,
61 query: String::new(),
62 cur: 0,
63 }
64}
65
66impl AutocompletePrompt {
67 pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
69 self.options.push(AcOption {
70 value: value.into(),
71 label: label.into(),
72 hint: None,
73 });
74 self
75 }
76 pub fn hint(mut self, text: impl Into<String>) -> Self {
78 if let Some(last) = self.options.last_mut() {
79 last.hint = Some(text.into());
80 }
81 self
82 }
83 pub fn placeholder(mut self, p: impl Into<String>) -> Self {
85 self.placeholder = Some(p.into());
86 self
87 }
88 pub fn max_items(mut self, n: usize) -> Self {
90 self.max_items = n.max(1);
91 self
92 }
93
94 pub fn run(self) -> Result<AutocompleteSelected> {
96 assert!(!self.options.is_empty(), "autocomplete: no options added");
97 super::core::run(self)
98 }
99
100 fn filter(&self) -> Vec<AcOption> {
101 let q = self.query.to_lowercase();
102 self.options
103 .iter()
104 .filter(|o| {
105 q.is_empty()
106 || o.label.to_lowercase().contains(&q)
107 || o.value.to_lowercase().contains(&q)
108 })
109 .take(self.max_items)
110 .cloned()
111 .collect()
112 }
113}
114
115impl Prompt for AutocompletePrompt {
116 type Output = AutocompleteSelected;
117
118 fn handle(&mut self, key: Key) -> Step<AutocompleteSelected> {
119 match key {
120 Key::Char(c) => {
121 self.query.push(c);
122 self.cur = 0;
123 Step::Continue
124 }
125 Key::Backspace => {
126 self.query.pop();
127 self.cur = 0;
128 Step::Continue
129 }
130 Key::Up => {
131 if self.cur > 0 {
132 self.cur -= 1;
133 }
134 Step::Continue
135 }
136 Key::Down => {
137 if self.cur + 1 < self.filter().len() {
138 self.cur += 1;
139 }
140 Step::Continue
141 }
142 Key::Enter => {
143 let f = self.filter();
144 if f.is_empty() {
145 Step::Continue
146 } else {
147 let opt = &f[self.cur];
148 Step::Submit(AutocompleteSelected {
149 value: opt.value.clone(),
150 label: opt.label.clone(),
151 })
152 }
153 }
154 Key::Escape | Key::Interrupt => Step::Cancel,
155 _ => Step::Continue,
156 }
157 }
158
159 fn render(&self, _ctx: RenderCtx) -> Frame {
160 let mut f: Frame = Vec::with_capacity(self.max_items + 5);
161 f.push(theme::label(&self.label));
162 f.push(theme::hint(""));
163 let ph = self.placeholder.as_deref().unwrap_or("Type to search...");
164 let search = if self.query.is_empty() {
165 format!(
166 "{} {} {}",
167 paint(DIM, "│"),
168 paint(DIM, "Search:"),
169 paint(DIM, ph)
170 )
171 } else {
172 format!(
173 "{} {} {}",
174 paint(DIM, "│"),
175 paint(DIM, "Search:"),
176 paint(super::settings::colors().input, &self.query)
177 )
178 };
179 f.push(search);
180 let filtered = self.filter();
181 if filtered.is_empty() {
182 f.push(format!("{} {}", paint(DIM, "│"), paint(DIM, "No matches")));
183 } else {
184 for (i, opt) in filtered.iter().enumerate() {
185 let active = i == self.cur;
186 let bullet = if active {
187 paint(OK, "●")
188 } else {
189 paint(DIM, "○")
190 };
191 let label = if active {
192 paint(WHITE, &opt.label)
193 } else {
194 paint(DIM, &opt.label)
195 };
196 let hint_sfx = theme::hint_inline(opt.hint.as_deref());
197 let hint_col = if active {
198 hint_sfx
199 } else {
200 paint(DIM, &hint_sfx)
201 };
202 f.push(format!(
203 "{} {} {}{}",
204 paint(DIM, "│"),
205 bullet,
206 label,
207 hint_col
208 ));
209 }
210 }
211 f.push(theme::hint(
212 "↑/↓ to navigate · Enter to confirm · type to search",
213 ));
214 f.push(theme::frame_bot(None));
215 f
216 }
217
218 fn render_answered(&self, value: &AutocompleteSelected) -> Frame {
219 theme::answered(&self.label, &value.label)
220 .split("\r\n")
221 .map(String::from)
222 .collect()
223 }
224
225 fn run_fallback(self) -> Result<AutocompleteSelected> {
226 use std::io::Write;
227 let mut out = std::io::stderr();
228 writeln!(out, " {}", self.label).map_err(PromptError::Io)?;
229 for (i, opt) in self.options.iter().enumerate() {
230 match &opt.hint {
231 Some(h) => writeln!(out, " {}. {} ({})", i + 1, opt.label, h),
232 None => writeln!(out, " {}. {}", i + 1, opt.label),
233 }
234 .map_err(PromptError::Io)?;
235 }
236 loop {
237 write!(out, " Enter number (1–{}): ", self.options.len()).map_err(PromptError::Io)?;
238 out.flush().map_err(PromptError::Io)?;
239 let line = super::engine::fallback::read_line_raw()?;
240 if let Ok(n) = line.parse::<usize>() {
241 if n >= 1 && n <= self.options.len() {
242 let opt = &self.options[n - 1];
243 return Ok(AutocompleteSelected {
244 value: opt.value.clone(),
245 label: opt.label.clone(),
246 });
247 }
248 }
249 writeln!(
250 out,
251 " Please enter a number between 1 and {}.",
252 self.options.len()
253 )
254 .map_err(PromptError::Io)?;
255 }
256 }
257}