Skip to main content

rich_interact/components/
confirm.rs

1//! A confirmation sheet (#470): what will happen, then a choice.
2//!
3//! The body is any renderables (a diff, a table of affected files, a
4//! summary) in a scrollable viewport, warnings are listed under it, and the
5//! choices are more than yes and no when the caller needs them (`Apply`,
6//! `Apply all`, `Skip`, `Cancel`). A choice's key picks it directly; Left,
7//! Right and Tab move between them and Enter picks the focused one. Every
8//! key comes from the [keymap](Component::keymap), so it can be rebound.
9
10use 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/// One answer: an id the caller matches on, its label and its key.
22#[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/// A question with a body, warnings and choices. Returns the chosen id.
40#[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    /// Rows the body may take; the rest of the terminal otherwise.
50    body_height: Option<usize>,
51    theme: Theme,
52    mouse: bool,
53    answer: Option<Option<String>>,
54    /// Keys rebound on this sheet ([`Confirm::rebind`]).
55    rebound: Keymap,
56}
57
58impl Confirm {
59    /// A yes-or-no question (`y`, `n`), answering `"yes"` or `"no"`.
60    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    /// Make `keys` do `action` (in context `confirm`: `pick`, `cancel`,
78    /// `previous`, `next`, `choose-ID` for each choice, and the scroll
79    /// actions when there is a body) on this sheet only; no keys unbinds
80    /// it.
81    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
82        self.rebound.rebind(action, keys);
83        self
84    }
85
86    /// Show `renderable` in the body, after anything added before.
87    pub fn body(mut self, renderable: impl Renderable + Send + Sync + 'static) -> Self {
88        self.body.push(Arc::new(renderable));
89        self
90    }
91
92    /// A warning line under the body.
93    pub fn warning(mut self, warning: impl Into<String>) -> Self {
94        self.warnings.push(warning.into());
95        self
96    }
97
98    /// Replace the choices.
99    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    /// Focus this choice first, and answer it without a terminal when the
107    /// policy asks for defaults.
108    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    /// Let the body take at most `rows` rows.
117    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    /// Report the mouse (#476): the choices are buttons, a click picks
128    /// one, and the wheel scrolls the body.
129    pub fn with_mouse(mut self, on: bool) -> Self {
130        self.mouse = on;
131        self
132    }
133
134    /// The choice whose button covers `column` of the choices row.
135    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    /// The row the choices are on.
148    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        // The question, warnings, the choices and the hint line stay.
159        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        // A choice's key picks it in either case.
204        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            // As written, and lowercase: an uppercase key answers to the
246            // lowercase press too, as in 0.0.14 (a lowercase one answers to
247            // the uppercase press through the fallback in `handle`).
248            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        // `question` ends with ` › ` for an answer; drop it until one.
272        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        // Before the first event the viewport is empty: render the body
286        // directly, from the top.
287        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            // Input ended: the default is the answer, as for an empty line.
355            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}