rich_interact/components/
confirm.rs1use std::sync::Arc;
11
12use rich::{Renderable, Segment, Style};
13
14use crate::component::{Component, Context, Flow, View};
15use crate::components::{Theme, fit, plain, question, text};
16use crate::event::{Event, Key, KeyCode};
17use crate::keymap::{Binding, Keymap, keys};
18use crate::policy::{LineIo, NotInteractive};
19use crate::viewport::Viewport;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Choice {
24 pub id: String,
25 pub label: String,
26 pub key: char,
27}
28
29impl Choice {
30 pub fn new(id: impl Into<String>, label: impl Into<String>, key: char) -> Choice {
31 Choice {
32 id: id.into(),
33 label: label.into(),
34 key,
35 }
36 }
37}
38
39#[must_use = "a component does nothing until it is run or mounted"]
41pub struct Confirm {
42 title: String,
43 body: Vec<Arc<dyn Renderable + Send + Sync>>,
44 warnings: Vec<String>,
45 choices: Vec<Choice>,
46 focus: usize,
47 default: Option<usize>,
48 viewport: Viewport,
49 body_height: Option<usize>,
51 theme: Theme,
52 mouse: bool,
53 answer: Option<Option<String>>,
54 rebound: Keymap,
56}
57
58impl Confirm {
59 pub fn new(title: impl Into<String>) -> Confirm {
61 Confirm {
62 title: title.into(),
63 body: Vec::new(),
64 warnings: Vec::new(),
65 choices: vec![Choice::new("yes", "Yes", 'y'), Choice::new("no", "No", 'n')],
66 focus: 0,
67 default: None,
68 viewport: Viewport::default(),
69 body_height: None,
70 theme: Theme::default(),
71 mouse: false,
72 answer: None,
73 rebound: Keymap::new("confirm"),
74 }
75 }
76
77 pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
82 self.rebound.rebind(action, keys);
83 self
84 }
85
86 pub fn body(mut self, renderable: impl Renderable + Send + Sync + 'static) -> Self {
88 self.body.push(Arc::new(renderable));
89 self
90 }
91
92 pub fn warning(mut self, warning: impl Into<String>) -> Self {
94 self.warnings.push(warning.into());
95 self
96 }
97
98 pub fn choices(mut self, choices: impl IntoIterator<Item = Choice>) -> Self {
100 self.choices = choices.into_iter().collect();
101 self.focus = 0;
102 self.default = None;
103 self
104 }
105
106 pub fn default(mut self, id: &str) -> Self {
109 if let Some(index) = self.choices.iter().position(|choice| choice.id == id) {
110 self.focus = index;
111 self.default = Some(index);
112 }
113 self
114 }
115
116 pub fn body_height(mut self, rows: usize) -> Self {
118 self.body_height = Some(rows.max(1));
119 self
120 }
121
122 pub fn theme(mut self, theme: Theme) -> Self {
123 self.theme = theme;
124 self
125 }
126
127 pub fn with_mouse(mut self, on: bool) -> Self {
130 self.mouse = on;
131 self
132 }
133
134 fn button_at(&self, column: usize) -> Option<usize> {
136 let mut at = 2;
137 for (index, choice) in self.choices.iter().enumerate() {
138 let width = rich::cells::cell_len(&choice.label) + 2;
139 if (at..at + width).contains(&column) {
140 return Some(index);
141 }
142 at += width + 1;
143 }
144 None
145 }
146
147 fn buttons_row(&self, context: &Context<'_>) -> usize {
149 let body = if self.viewport.is_empty() {
150 self.lines(context).len()
151 } else {
152 self.viewport.len().saturating_sub(self.viewport.offset())
153 };
154 1 + body.min(self.page(context)) + self.warnings.len()
155 }
156
157 fn page(&self, context: &Context<'_>) -> usize {
158 let fixed = 3 + self.warnings.len();
160 let room = context.height.saturating_sub(fixed).max(1);
161 self.body_height.unwrap_or(room).min(room)
162 }
163
164 fn lines(&self, context: &Context<'_>) -> Vec<Vec<Segment>> {
165 let width = context.width.saturating_sub(2).max(1);
166 self.body
167 .iter()
168 .flat_map(|renderable| context.lines_at(&**renderable, width))
169 .collect()
170 }
171
172 fn pick(&mut self, index: usize) -> Flow<String> {
173 let id = self.choices[index].id.clone();
174 self.answer = Some(Some(self.choices[index].label.clone()));
175 Flow::Done(id)
176 }
177}
178
179impl Component for Confirm {
180 type Output = String;
181
182 fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<String> {
183 if self.viewport.is_empty() && !self.body.is_empty() {
184 self.viewport.set_lines(self.lines(context));
185 }
186 if let Event::Resize { .. } = event {
187 self.viewport.set_lines(self.lines(context));
188 }
189 let page = self.page(context);
190 if let Some(mouse) = event.mouse().filter(|mouse| mouse.is_click()) {
191 if mouse.row as usize == self.buttons_row(context)
192 && let Some(index) = self.button_at(mouse.column as usize)
193 {
194 return self.pick(index);
195 }
196 return Flow::Continue;
197 }
198 let Some(key) = event.key() else {
199 self.viewport.handle_scroll(event, page);
200 return Flow::Continue;
201 };
202 let keymap = self.keymap();
203 let action = keymap.action(key).or_else(|| match key.code {
205 KeyCode::Char(c) if c.is_uppercase() => {
206 let mut lower = key;
207 lower.code = KeyCode::Char(c.to_ascii_lowercase());
208 keymap.action(lower)
209 }
210 _ => None,
211 });
212 let action = action.map(str::to_string);
213 let count = self.choices.len();
214 match action.as_deref() {
215 Some(action) if action.starts_with("choose-") => {
216 if let Some(index) = self
217 .choices
218 .iter()
219 .position(|choice| action["choose-".len()..] == choice.id)
220 {
221 return self.pick(index);
222 }
223 return Flow::Ignored;
224 }
225 Some("pick") if count > 0 => return self.pick(self.focus),
226 Some("cancel") => {
227 self.answer = Some(None);
228 return Flow::Cancel;
229 }
230 Some("previous") => self.focus = (self.focus + count - 1) % count.max(1),
231 Some("next") => self.focus = (self.focus + 1) % count.max(1),
232 Some(action) if self.viewport.act(action, page) => {}
233 _ => return Flow::Ignored,
234 }
235 Flow::Continue
236 }
237
238 fn keymap(&self) -> Keymap {
239 let mut keymap = Keymap::new("confirm")
240 .bind("pick", keys("enter"), "pick the focused choice")
241 .bind("cancel", keys("escape"), "cancel")
242 .bind("previous", keys("left shift+tab"), "previous choice")
243 .bind("next", keys("right tab"), "next choice");
244 for choice in &self.choices {
245 let lower = choice.key.to_ascii_lowercase();
249 let mut keys = vec![Key::char(choice.key)];
250 if lower != choice.key {
251 keys.push(Key::char(lower));
252 }
253 keymap.add(Binding::new(
254 "confirm",
255 format!("choose-{}", choice.id),
256 keys,
257 choice.label.clone(),
258 ));
259 }
260 if !self.body.is_empty() {
261 keymap.extend(crate::kit::ScrollState::keymap("confirm"));
262 }
263 keymap.extend(self.rebound.clone());
264 keymap
265 }
266
267 fn render(&self, context: &Context<'_>) -> View {
268 let width = context.width;
269 let theme = &self.theme;
270 let mut title = question(theme, &self.title);
271 title.truncate(title.len() - 3);
273 if let Some(answer) = &self.answer {
274 title.push(plain(" "));
275 title.push(text("›", &theme.hint));
276 title.push(plain(" "));
277 title.push(match answer {
278 Some(answer) => text(answer.clone(), &theme.answer),
279 None => text("cancelled", &theme.hint),
280 });
281 return View::new(vec![fit(title, width)]);
282 }
283 let mut lines = vec![fit(title, width)];
284 let page = self.page(context);
285 let body = if self.viewport.is_empty() {
288 self.lines(context)
289 } else {
290 self.viewport.lines().to_vec()
291 };
292 let offset = if self.viewport.is_empty() {
293 0
294 } else {
295 self.viewport.offset()
296 };
297 for line in body.iter().skip(offset).take(page) {
298 let mut row = vec![plain(" ")];
299 row.extend(line.iter().cloned());
300 lines.push(fit(row, width));
301 }
302 for warning in &self.warnings {
303 let style = Style::parse("yellow").expect("style");
304 lines.push(fit(vec![text(format!(" ⚠ {warning}"), &style)], width));
305 }
306 let mut row = vec![plain(" ")];
307 for (index, choice) in self.choices.iter().enumerate() {
308 let label = format!(" {} ", choice.label);
309 row.push(if index == self.focus {
310 text(
311 label,
312 &theme
313 .focused
314 .combine(&Style::parse("reverse").expect("style")),
315 )
316 } else {
317 plain(label)
318 });
319 row.push(plain(" "));
320 }
321 lines.push(fit(row, width));
322 let keys: Vec<String> = self
323 .choices
324 .iter()
325 .map(|choice| choice.key.to_string())
326 .collect();
327 let mut hint = format!(" {} · ←→ move · enter choose · esc cancel", keys.join("/"));
328 if body.len() > page {
329 hint.push_str(&format!(" · ↑↓ scroll ({})", self.viewport.status(page)));
330 }
331 lines.push(fit(vec![text(hint, &theme.hint)], width));
332 View::new(lines)
333 }
334
335 fn mouse(&self) -> bool {
336 self.mouse
337 }
338
339 fn default_value(&self) -> Option<String> {
340 self.default.map(|index| self.choices[index].id.clone())
341 }
342
343 fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<String>, NotInteractive> {
344 for warning in &self.warnings {
345 io.write(&format!("warning: {warning}\n"));
346 }
347 let keys: Vec<String> = self
348 .choices
349 .iter()
350 .map(|choice| format!("{}={}", choice.key, choice.label))
351 .collect();
352 io.write(&format!("{} [{}]: ", self.title, keys.join(", ")));
353 let Some(line) = io.read_line() else {
354 return match self.default {
356 Some(index) => Ok(Some(self.choices[index].id.clone())),
357 None => Err(NotInteractive::Ended),
358 };
359 };
360 let answer = line.trim().to_lowercase();
361 if answer.is_empty()
362 && let Some(index) = self.default
363 {
364 return Ok(Some(self.choices[index].id.clone()));
365 }
366 self.choices
367 .iter()
368 .find(|choice| {
369 answer == choice.key.to_string()
370 || answer == choice.id.to_lowercase()
371 || answer == choice.label.to_lowercase()
372 })
373 .map(|choice| Some(choice.id.clone()))
374 .ok_or_else(|| NotInteractive::Invalid(format!("not one of the choices: {line:?}")))
375 }
376}