Skip to main content

rich_interact/components/
approve.rs

1//! An approval queue (#471): review pending items one by one or in bulk,
2//! and approve, reject or skip each, with a reason when one is wanted.
3//!
4//! The queue is a list of items, each with its decision mark (`·` pending,
5//! `✓` approved, `✗` rejected, `↷` skipped), and a detail pane for the
6//! focused item: its label, description, details and decision, then its
7//! [`Preview`](crate::Preview). `a`, `r` and `s` decide the focused item and
8//! move on to the next pending one; `A`, `R` and `S` first ask for a
9//! reason. Space marks items, Ctrl+A marks every pending one, and a
10//! decision then applies to all the marked items at once. `u` takes a
11//! decision back. Enter submits the [`ApprovalReport`]: every item with its
12//! decision and reason, pending ones included; Escape cancels. The queue
13//! only records decisions: what they lead to is the caller's.
14//!
15//! With the mouse on, a click focuses a row, a click on its mark column
16//! marks it, the wheel moves, and the buttons under the list are buttons.
17//!
18//! Decisions are drawn in the console theme's `approve.approved`,
19//! `approve.rejected`, `approve.skipped` and `approve.pending` styles
20//! (green, red, yellow and dim unless the theme sets them), so a theme can
21//! restyle them without code.
22//!
23//! Without a terminal it asks item by item: `a`, `r` or `s` (or `yes`,
24//! `no`, `skip`), with a reason after a colon (`r: not this week`); an
25//! empty line skips, and `q` or the end of input leaves the rest pending.
26//!
27//! ```
28//! use rich_interact::components::{ApprovalQueue, Verdict};
29//! use rich_interact::{headless, Outcome};
30//!
31//! let queue = ApprovalQueue::new("Deploys", ["api", "web", "db"]);
32//! // Approve api, reject web with a reason, skip db, submit.
33//! let script = headless::Script::new().keys("a R").text("flaky").keys("enter s enter");
34//! let (outcome, _) = headless::run(queue, script, 80, 12);
35//! let Outcome::Done(report) = outcome.unwrap() else { panic!() };
36//! assert_eq!(report.verdicts(), [Verdict::Approved, Verdict::Rejected, Verdict::Skipped]);
37//! assert_eq!(report.decisions[1].reason.as_deref(), Some("flaky"));
38//! ```
39
40use std::cell::Cell;
41
42use rich::{Segment, Style};
43
44use crate::component::{Component, Context, Flow, View};
45use crate::components::{PreviewLayout, Theme, fit, pad, pasted, plain, question, text};
46use crate::event::{Button, Event, KeyCode, MouseKind};
47use crate::item::Item;
48use crate::keymap::{Keymap, keys};
49use crate::kit::{ListState, TextBuffer};
50use crate::policy::{LineIo, NotInteractive};
51
52/// What was decided about one item.
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
54pub enum Verdict {
55    /// Not decided yet.
56    #[default]
57    Pending,
58    Approved,
59    Rejected,
60    Skipped,
61}
62
63impl Verdict {
64    /// `pending`, `approved`, `rejected` or `skipped`.
65    pub fn as_str(self) -> &'static str {
66        match self {
67            Verdict::Pending => "pending",
68            Verdict::Approved => "approved",
69            Verdict::Rejected => "rejected",
70            Verdict::Skipped => "skipped",
71        }
72    }
73
74    /// The name [`as_str`](Self::as_str) gives, or the verb (`approve`,
75    /// `reject`, `skip`).
76    pub fn parse(name: &str) -> Option<Verdict> {
77        Some(match name {
78            "pending" => Verdict::Pending,
79            "approved" | "approve" => Verdict::Approved,
80            "rejected" | "reject" => Verdict::Rejected,
81            "skipped" | "skip" => Verdict::Skipped,
82            _ => return None,
83        })
84    }
85
86    /// The mark a row shows: one cell that reads without colour.
87    pub fn mark(self) -> &'static str {
88        match self {
89            Verdict::Pending => "·",
90            Verdict::Approved => "✓",
91            Verdict::Rejected => "✗",
92            Verdict::Skipped => "↷",
93        }
94    }
95
96    /// The console theme key its style is read from (`approve.approved`).
97    pub fn theme_key(self) -> &'static str {
98        match self {
99            Verdict::Pending => "approve.pending",
100            Verdict::Approved => "approve.approved",
101            Verdict::Rejected => "approve.rejected",
102            Verdict::Skipped => "approve.skipped",
103        }
104    }
105
106    /// The style used when the theme has no [`theme_key`](Self::theme_key).
107    fn default_style(self) -> &'static str {
108        match self {
109            Verdict::Pending => "dim",
110            Verdict::Approved => "bold green",
111            Verdict::Rejected => "bold red",
112            Verdict::Skipped => "yellow",
113        }
114    }
115
116    /// The verb, for a prompt: "approve".
117    fn verb(self) -> &'static str {
118        match self {
119            Verdict::Pending => "undo",
120            Verdict::Approved => "approve",
121            Verdict::Rejected => "reject",
122            Verdict::Skipped => "skip",
123        }
124    }
125}
126
127impl std::fmt::Display for Verdict {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.write_str(self.as_str())
130    }
131}
132
133/// One item and what was decided about it.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct Decision<T> {
136    /// The item's index in the queue.
137    pub index: usize,
138    pub value: T,
139    pub label: String,
140    pub verdict: Verdict,
141    pub reason: Option<String>,
142}
143
144/// What a review ended with: one [`Decision`] for every item, in the
145/// queue's order, pending ones included.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct ApprovalReport<T> {
148    pub decisions: Vec<Decision<T>>,
149}
150
151impl<T> ApprovalReport<T> {
152    /// Each item's verdict, in order.
153    pub fn verdicts(&self) -> Vec<Verdict> {
154        self.decisions.iter().map(|d| d.verdict).collect()
155    }
156
157    /// The decisions with `verdict`.
158    pub fn with(&self, verdict: Verdict) -> impl Iterator<Item = &Decision<T>> {
159        self.decisions.iter().filter(move |d| d.verdict == verdict)
160    }
161
162    /// The approved items' values.
163    pub fn approved(&self) -> Vec<&T> {
164        self.with(Verdict::Approved).map(|d| &d.value).collect()
165    }
166
167    /// The rejected items' values.
168    pub fn rejected(&self) -> Vec<&T> {
169        self.with(Verdict::Rejected).map(|d| &d.value).collect()
170    }
171
172    /// How many items have `verdict`.
173    pub fn count(&self, verdict: Verdict) -> usize {
174        self.with(verdict).count()
175    }
176
177    /// `2 approved · 1 rejected · 0 skipped · 3 pending`.
178    pub fn summary(&self) -> String {
179        summary(&self.verdicts())
180    }
181}
182
183fn summary(verdicts: &[Verdict]) -> String {
184    let count = |verdict| verdicts.iter().filter(|v| **v == verdict).count();
185    format!(
186        "{} approved · {} rejected · {} skipped · {} pending",
187        count(Verdict::Approved),
188        count(Verdict::Rejected),
189        count(Verdict::Skipped),
190        count(Verdict::Pending)
191    )
192}
193
194/// The keys of an [`ApprovalQueue`], in context `approve`.
195pub fn approve_keymap() -> Keymap {
196    Keymap::new("approve")
197        .bind("approve", keys("a"), "approve")
198        .bind("reject", keys("r"), "reject")
199        .bind("skip", keys("s"), "skip")
200        .bind("approve-reason", keys("A"), "approve, with a reason")
201        .bind("reject-reason", keys("R"), "reject, with a reason")
202        .bind("skip-reason", keys("S"), "skip, with a reason")
203        .bind("undo", keys("u"), "back to pending")
204        .bind("mark", keys("space"), "mark for a bulk decision")
205        .bind("mark-all", keys("ctrl+a"), "mark every pending item")
206        .bind("next-pending", keys("n tab"), "next pending item")
207        .bind("up", keys("up k ctrl+p"), "move up")
208        .bind("down", keys("down j ctrl+n"), "move down")
209        .bind("page-up", keys("pageup"), "page up")
210        .bind("page-down", keys("pagedown"), "page down")
211        .bind("first", keys("home g"), "first")
212        .bind("last", keys("end G"), "last")
213        .bind("preview", keys("p"), "show or hide the details")
214        .bind("submit", keys("enter"), "submit the decisions")
215        .bind("cancel", keys("escape"), "cancel")
216}
217
218/// The buttons under the list: label and action, in order.
219const BUTTONS: [(&str, &str); 5] = [
220    ("[a] approve", "approve"),
221    ("[r] reject", "reject"),
222    ("[s] skip", "skip"),
223    ("[u] undo", "undo"),
224    ("[enter] submit", "submit"),
225];
226
227/// A reason being typed: for which decision and which items.
228struct ReasonEditor {
229    verdict: Verdict,
230    targets: Vec<usize>,
231    buffer: TextBuffer,
232    error: Option<String>,
233}
234
235/// A queue of pending items to approve, reject or skip. Returns an
236/// [`ApprovalReport`].
237#[must_use = "a component does nothing until it is run or mounted"]
238pub struct ApprovalQueue<T> {
239    prompt: String,
240    items: Vec<Item<T>>,
241    verdicts: Vec<Verdict>,
242    reasons: Vec<Option<String>>,
243    /// The cursor, the scroll, and the marks for a bulk decision.
244    list: ListState,
245    height: usize,
246    preview: PreviewLayout,
247    preview_height: usize,
248    theme: Theme,
249    mouse: bool,
250    keymap: Keymap,
251    /// Decisions that cannot be made without a reason.
252    required: Vec<Verdict>,
253    /// After a decision, move to the next pending item.
254    advance: bool,
255    /// Submit by itself once nothing is pending.
256    auto_submit: bool,
257    reason: Option<ReasonEditor>,
258    /// A line in place of the buttons until the next key.
259    status: Option<String>,
260    /// Once finished: the answer shown in place of the queue.
261    answer: Option<Option<String>>,
262    /// Rows on screen, from the last context.
263    space: Cell<usize>,
264}
265
266impl<T> ApprovalQueue<T> {
267    pub fn new<I, V>(prompt: impl Into<String>, items: I) -> ApprovalQueue<T>
268    where
269        I: IntoIterator<Item = V>,
270        V: Into<Item<T>>,
271    {
272        let items: Vec<Item<T>> = items.into_iter().map(Into::into).collect();
273        let mut list = ListState::new();
274        list.set_len(items.len());
275        list.clear_selection(items.len());
276        ApprovalQueue {
277            prompt: prompt.into(),
278            verdicts: vec![Verdict::Pending; items.len()],
279            reasons: vec![None; items.len()],
280            items,
281            list,
282            height: 10,
283            preview: PreviewLayout::Auto,
284            preview_height: 8,
285            theme: Theme::default(),
286            mouse: false,
287            keymap: approve_keymap(),
288            required: Vec::new(),
289            advance: true,
290            auto_submit: false,
291            reason: None,
292            status: None,
293            answer: None,
294            space: Cell::new(usize::MAX),
295        }
296    }
297
298    /// Show at most `rows` items at once (default 10).
299    pub fn height(mut self, rows: usize) -> Self {
300        self.height = rows.max(1);
301        self
302    }
303
304    /// Where the detail pane goes (default: beside the list from 72
305    /// columns, below it when narrower); `Hidden` hides it.
306    pub fn preview(mut self, layout: PreviewLayout) -> Self {
307        self.preview = layout;
308        self
309    }
310
311    /// Rows the detail pane takes when it is below the list (default 8).
312    pub fn preview_height(mut self, rows: usize) -> Self {
313        self.preview_height = rows.max(1);
314        self
315    }
316
317    pub fn theme(mut self, theme: Theme) -> Self {
318        self.theme = theme;
319        self
320    }
321
322    /// Report the mouse (#476): rows, the mark column and the buttons
323    /// click, and the wheel moves.
324    pub fn with_mouse(mut self, on: bool) -> Self {
325        self.mouse = on;
326        self
327    }
328
329    /// Make `keys` do `action` (see [`approve_keymap`]) on this queue
330    /// only; no keys unbinds it.
331    pub fn rebind(
332        mut self,
333        action: &str,
334        keys: impl IntoIterator<Item = crate::event::Key>,
335    ) -> Self {
336        self.keymap.rebind(action, keys);
337        self
338    }
339
340    /// Ask for a reason whenever an item gets `verdict` (a rejection, say),
341    /// and refuse an empty one.
342    pub fn require_reason(mut self, verdict: Verdict) -> Self {
343        if verdict != Verdict::Pending && !self.required.contains(&verdict) {
344            self.required.push(verdict);
345        }
346        self
347    }
348
349    /// Whether a decision moves the cursor on to the next pending item
350    /// (default on).
351    pub fn advance(mut self, on: bool) -> Self {
352        self.advance = on;
353        self
354    }
355
356    /// Submit by itself as soon as nothing is pending (default off: Enter
357    /// submits).
358    pub fn auto_submit(mut self, on: bool) -> Self {
359        self.auto_submit = on;
360        self
361    }
362
363    /// Start item `index` decided: a review picked up where it was left.
364    pub fn decided(mut self, index: usize, verdict: Verdict, reason: Option<String>) -> Self {
365        if index < self.items.len() {
366            self.verdicts[index] = verdict;
367            self.reasons[index] = reason;
368        }
369        self
370    }
371
372    pub fn items(&self) -> &[Item<T>] {
373        &self.items
374    }
375
376    /// Each item's verdict so far.
377    pub fn verdicts(&self) -> &[Verdict] {
378        &self.verdicts
379    }
380
381    /// Item `index`'s reason, if it has one.
382    pub fn reason(&self, index: usize) -> Option<&str> {
383        self.reasons.get(index)?.as_deref()
384    }
385
386    /// The focused item's index (none when the queue is empty).
387    pub fn focused(&self) -> Option<usize> {
388        (!self.items.is_empty()).then(|| self.list.cursor())
389    }
390
391    /// The items marked for a bulk decision.
392    pub fn marked(&self) -> Vec<usize> {
393        self.list.selected()
394    }
395
396    /// Whether a reason is being typed.
397    pub fn is_asking_reason(&self) -> bool {
398        self.reason.is_some()
399    }
400
401    /// The keys, with any rebinding.
402    pub fn approve_keymap(&self) -> &Keymap {
403        &self.keymap
404    }
405
406    /// `2 approved · 1 rejected · 0 skipped · 3 pending`, so far.
407    pub fn summary(&self) -> String {
408        summary(&self.verdicts)
409    }
410
411    /// Rows for the list on a terminal `height` rows tall, beside the
412    /// question and the buttons.
413    fn rows(&self) -> usize {
414        self.height
415            .min(self.items.len().max(1))
416            .min(self.space.get().saturating_sub(2).max(1))
417    }
418
419    fn step(&mut self, delta: isize) {
420        self.list.step(delta, self.rows());
421    }
422
423    /// The next pending item after `from`, wrapping round; `None` when
424    /// nothing is pending.
425    fn next_pending(&self, from: usize) -> Option<usize> {
426        let len = self.items.len();
427        (1..=len)
428            .map(|offset| (from + offset) % len)
429            .find(|&index| self.verdicts[index] == Verdict::Pending)
430    }
431
432    /// What a decision applies to: the marked items, or the focused one.
433    fn targets(&self) -> Vec<usize> {
434        let marked = self.list.selected();
435        if marked.is_empty() {
436            self.focused().into_iter().collect()
437        } else {
438            marked
439        }
440    }
441
442    /// Decide `targets` with `verdict` and `reason`, then move on.
443    fn apply(&mut self, verdict: Verdict, targets: &[usize], reason: Option<String>) {
444        for &index in targets {
445            self.verdicts[index] = verdict;
446            self.reasons[index] = reason.clone();
447        }
448        self.list.clear_selection(self.items.len());
449        if targets.len() > 1 {
450            self.status = Some(format!("{} {} items", verdict.as_str(), targets.len()));
451        }
452        if self.advance
453            && verdict != Verdict::Pending
454            && let Some(next) = self.next_pending(self.list.cursor())
455        {
456            self.list.select_row(next, self.rows());
457        }
458    }
459
460    /// Start deciding with `verdict`: at once, or after a reason.
461    fn decide(&mut self, verdict: Verdict, ask: bool) -> Flow<Vec<usize>> {
462        let targets = self.targets();
463        if targets.is_empty() {
464            return Flow::Continue;
465        }
466        if ask || self.required.contains(&verdict) {
467            // A single item's own reason, to edit.
468            let current = match targets[..] {
469                [index] => self.reasons[index].clone().unwrap_or_default(),
470                _ => String::new(),
471            };
472            self.reason = Some(ReasonEditor {
473                verdict,
474                targets,
475                buffer: TextBuffer::with_text(current),
476                error: None,
477            });
478            return Flow::Continue;
479        }
480        self.apply(verdict, &targets, None);
481        self.maybe_submit()
482    }
483
484    fn maybe_submit(&mut self) -> Flow<Vec<usize>> {
485        if self.auto_submit && !self.verdicts.contains(&Verdict::Pending) {
486            return self.submit();
487        }
488        Flow::Continue
489    }
490
491    fn submit(&mut self) -> Flow<Vec<usize>> {
492        self.reason = None;
493        self.answer = Some(Some(self.summary()));
494        Flow::Done((0..self.items.len()).collect())
495    }
496
497    fn reason_event(&mut self, event: &Event) -> Flow<Vec<usize>> {
498        let Some(editor) = self.reason.as_mut() else {
499            return Flow::Continue;
500        };
501        if let Event::Paste(text) = event {
502            editor.buffer.insert(&pasted(text, " "));
503            editor.error = None;
504            return Flow::Continue;
505        }
506        let Some(key) = event.key() else {
507            return Flow::Ignored;
508        };
509        let typing = !key.modifiers.ctrl && !key.modifiers.alt;
510        match key.code {
511            KeyCode::Enter => {
512                let reason = editor.buffer.text().trim().to_string();
513                if reason.is_empty() && self.required.contains(&editor.verdict) {
514                    editor.error = Some(format!("a reason is needed to {}", editor.verdict.verb()));
515                    return Flow::Continue;
516                }
517                let Some(editor) = self.reason.take() else {
518                    return Flow::Continue;
519                };
520                let reason = (!reason.is_empty()).then_some(reason);
521                self.apply(editor.verdict, &editor.targets, reason);
522                return self.maybe_submit();
523            }
524            KeyCode::Escape => self.reason = None,
525            KeyCode::Backspace => {
526                editor.buffer.backspace();
527            }
528            KeyCode::Delete => {
529                editor.buffer.delete();
530            }
531            KeyCode::Left => editor.buffer.left(),
532            KeyCode::Right => editor.buffer.right(),
533            KeyCode::Home => editor.buffer.home(),
534            KeyCode::End => editor.buffer.end(),
535            KeyCode::Char('u') if key.modifiers.ctrl => editor.buffer.delete_to_start(),
536            KeyCode::Char('w') if key.modifiers.ctrl => editor.buffer.delete_word(),
537            KeyCode::Char(c) if typing => {
538                editor.buffer.insert(c.encode_utf8(&mut [0; 4]));
539                editor.error = None;
540            }
541            _ => {}
542        }
543        Flow::Continue
544    }
545
546    fn act(&mut self, action: &str) -> Flow<Vec<usize>> {
547        let page = self.rows() as isize;
548        match action {
549            "approve" => return self.decide(Verdict::Approved, false),
550            "reject" => return self.decide(Verdict::Rejected, false),
551            "skip" => return self.decide(Verdict::Skipped, false),
552            "approve-reason" => return self.decide(Verdict::Approved, true),
553            "reject-reason" => return self.decide(Verdict::Rejected, true),
554            "skip-reason" => return self.decide(Verdict::Skipped, true),
555            "undo" => {
556                let targets = self.targets();
557                self.apply(Verdict::Pending, &targets, None);
558            }
559            "mark" => {
560                if let Some(index) = self.focused() {
561                    self.list.toggle(index);
562                    self.step(1);
563                }
564            }
565            "mark-all" => {
566                let pending: Vec<usize> = (0..self.items.len())
567                    .filter(|&index| self.verdicts[index] == Verdict::Pending)
568                    .collect();
569                let all = !pending.is_empty()
570                    && pending.iter().all(|&index| self.list.is_selected(index));
571                self.list.clear_selection(self.items.len());
572                if !all {
573                    for index in pending {
574                        self.list.set_selected(index, true);
575                    }
576                }
577            }
578            "next-pending" => {
579                if let Some(next) = self.next_pending(self.list.cursor()) {
580                    self.list.select_row(next, self.rows());
581                }
582            }
583            "up" => self.step(-1),
584            "down" => self.step(1),
585            "page-up" => self.step(-page),
586            "page-down" => self.step(page),
587            "first" => self.step(isize::MIN / 2),
588            "last" => self.step(isize::MAX / 2),
589            "preview" => {
590                self.preview = match self.preview {
591                    PreviewLayout::Hidden => PreviewLayout::Auto,
592                    _ => PreviewLayout::Hidden,
593                };
594            }
595            "submit" => return self.submit(),
596            "cancel" => {
597                self.answer = Some(None);
598                return Flow::Cancel;
599            }
600            _ => return Flow::Ignored,
601        }
602        Flow::Continue
603    }
604
605    /// The detail pane's layout at `width`, and the list's width beside it.
606    fn layout(&self, width: usize) -> (PreviewLayout, usize) {
607        if self.items.is_empty() {
608            return (PreviewLayout::Hidden, width);
609        }
610        let layout = match self.preview {
611            PreviewLayout::Auto if width >= 72 => PreviewLayout::Right,
612            PreviewLayout::Auto => PreviewLayout::Below,
613            layout => layout,
614        };
615        let left = (width * 45 / 100).max(20).min(width);
616        (
617            layout,
618            if layout == PreviewLayout::Right {
619                left
620            } else {
621                width
622            },
623        )
624    }
625
626    /// The buttons' cells on their row: `(from, to, action)`.
627    fn button_spans(&self) -> Vec<(usize, usize, &'static str)> {
628        let mut at = 2;
629        BUTTONS
630            .iter()
631            .map(|(label, action)| {
632                let span = (at, at + label.chars().count(), *action);
633                at = span.1 + 2;
634                span
635            })
636            .collect()
637    }
638
639    fn mouse_event(
640        &mut self,
641        kind: MouseKind,
642        column: usize,
643        row: usize,
644        width: usize,
645    ) -> Flow<Vec<usize>> {
646        let rows = self.rows();
647        let shown = self.list.visible(rows).len();
648        let (layout, left) = self.layout(width);
649        match kind {
650            MouseKind::ScrollUp => self.step(-1),
651            MouseKind::ScrollDown => self.step(1),
652            MouseKind::Down(Button::Left) => {
653                if row >= 1 && row < 1 + shown {
654                    if layout == PreviewLayout::Right && column >= left {
655                        return Flow::Continue;
656                    }
657                    let position = self.list.offset() + row - 1;
658                    self.list.select_row(position, rows);
659                    // The mark column, after the pointer.
660                    if (2..4).contains(&column) {
661                        self.list.toggle(position);
662                    }
663                } else if row == 1 + rows && self.reason.is_none() {
664                    let hit = self
665                        .button_spans()
666                        .into_iter()
667                        .find(|(from, to, _)| (*from..*to).contains(&column));
668                    if let Some((_, _, action)) = hit {
669                        return self.act(action);
670                    }
671                }
672            }
673            _ => return Flow::Ignored,
674        }
675        Flow::Continue
676    }
677
678    fn event(&mut self, event: &Event, context: &Context<'_>) -> Flow<Vec<usize>> {
679        self.space.set(context.height);
680        if matches!(event, Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) {
681            self.status = None;
682        }
683        if let Some(mouse) = event.mouse() {
684            return self.mouse_event(
685                mouse.kind,
686                mouse.column as usize,
687                mouse.row as usize,
688                context.width,
689            );
690        }
691        if self.reason.is_some() {
692            return self.reason_event(event);
693        }
694        let Some(key) = event.key() else {
695            return Flow::Ignored;
696        };
697        match self.keymap.action(key).map(str::to_string) {
698            Some(action) => self.act(&action),
699            None => Flow::Ignored,
700        }
701    }
702
703    fn style(&self, context: &Context<'_>, verdict: Verdict) -> Style {
704        context
705            .console
706            .theme()
707            .get(verdict.theme_key())
708            .cloned()
709            .unwrap_or_else(|| Style::parse(verdict.default_style()).expect("a built-in style"))
710    }
711
712    fn row(&self, context: &Context<'_>, position: usize, width: usize) -> Vec<Segment> {
713        let theme = &self.theme;
714        let item = &self.items[position];
715        let verdict = self.verdicts[position];
716        let focused = position == self.list.cursor();
717        let mut line = if focused {
718            vec![text(format!("{} ", theme.pointer), &theme.pointer_style)]
719        } else {
720            vec![plain("  ")]
721        };
722        line.push(if self.list.is_selected(position) {
723            text(format!("{} ", theme.checked), &theme.checked_style)
724        } else {
725            plain("  ")
726        });
727        let style = self.style(context, verdict);
728        line.push(text(format!("{} ", verdict.mark()), &style));
729        line.push(if focused {
730            text(item.label.clone(), &theme.focused)
731        } else {
732            plain(item.label.clone())
733        });
734        match (&self.reasons[position], &item.description) {
735            (Some(reason), _) => line.push(text(format!("  — {reason}"), &style)),
736            (None, Some(description)) => line.push(text(format!("  {description}"), &theme.hint)),
737            _ => {}
738        }
739        fit(line, width)
740    }
741
742    /// The focused item's details: label, description, metadata, decision,
743    /// then its preview.
744    fn detail(&self, context: &Context<'_>, width: usize) -> Vec<Vec<Segment>> {
745        let Some(index) = self.focused() else {
746            return Vec::new();
747        };
748        let theme = &self.theme;
749        let item = &self.items[index];
750        let verdict = self.verdicts[index];
751        let mut lines = vec![fit(vec![text(item.label.clone(), &theme.focused)], width)];
752        if let Some(description) = &item.description {
753            lines.extend(context.lines_at(&rich::Text::new(description.clone()), width));
754        }
755        for (key, value) in &item.metadata {
756            lines.push(fit(
757                vec![text(format!("{key}: "), &theme.hint), plain(value.clone())],
758                width,
759            ));
760        }
761        let mut decided = vec![text(
762            format!("{} {}", verdict.mark(), verdict.as_str()),
763            &self.style(context, verdict),
764        )];
765        if let Some(reason) = &self.reasons[index] {
766            decided.push(plain(format!(" — {reason}")));
767        }
768        lines.push(fit(decided, width));
769        if let Some(preview) = &item.preview {
770            lines.push(Vec::new());
771            lines.extend(context.lines_at(&*preview.renderable(), width));
772        }
773        lines
774    }
775
776    /// The row under the list: the reason being typed, a status, or the
777    /// buttons and counts.
778    fn footer(&self, width: usize) -> (Vec<Segment>, Option<usize>) {
779        let theme = &self.theme;
780        if let Some(editor) = &self.reason {
781            let count = editor.targets.len();
782            let what = if count == 1 {
783                String::new()
784            } else {
785                format!(" {count} items")
786            };
787            let mut line = vec![text(
788                format!("  Reason to {}{what} › ", editor.verdict.verb()),
789                &theme.prompt,
790            )];
791            let used = crate::components::width(&line);
792            if let Some(error) = &editor.error {
793                line.push(text(error.clone(), &theme.error));
794                return (fit(line, width), None);
795            }
796            let (shown, caret) = editor.buffer.window(width.saturating_sub(used + 1), None);
797            line.push(plain(shown));
798            return (
799                fit(line, width),
800                Some((used + caret).min(width.saturating_sub(1))),
801            );
802        }
803        if let Some(status) = &self.status {
804            return (
805                fit(vec![text(format!("  {status}"), &theme.hint)], width),
806                None,
807            );
808        }
809        let mut line = vec![plain("  ")];
810        for (at, (label, _)) in BUTTONS.iter().enumerate() {
811            if at > 0 {
812                line.push(plain("  "));
813            }
814            line.push(text(*label, &theme.hint));
815        }
816        let marked = self.list.selected_count();
817        if marked > 0 {
818            line.push(text(format!("  · {marked} marked"), &theme.checked_style));
819        }
820        (fit(line, width), None)
821    }
822
823    fn render_view(&self, context: &Context<'_>) -> View {
824        self.space.set(context.height);
825        let width = context.width;
826        let theme = &self.theme;
827        let mut header = question(theme, &self.prompt);
828        if let Some(answer) = &self.answer {
829            header.push(match answer {
830                Some(answer) => text(answer.clone(), &theme.answer),
831                None => text("cancelled", &theme.hint),
832            });
833            return View::new(vec![fit(header, width)]);
834        }
835        let done = self
836            .verdicts
837            .iter()
838            .filter(|v| **v != Verdict::Pending)
839            .count();
840        header.push(text(
841            format!("{done}/{} decided · {}", self.items.len(), self.summary()),
842            &theme.hint,
843        ));
844        let mut lines = vec![fit(header, width)];
845        let rows = self.rows();
846        let (layout, left) = self.layout(width);
847        let mut list: Vec<Vec<Segment>> = self
848            .list
849            .visible(rows)
850            .map(|position| self.row(context, position, left))
851            .collect();
852        if self.items.is_empty() {
853            list.push(vec![text("  nothing to review", &theme.hint)]);
854        }
855        list.resize_with(rows, Vec::new);
856        let (footer, caret) = self.footer(width);
857        match layout {
858            PreviewLayout::Right => {
859                let right = width.saturating_sub(left + 3).max(1);
860                let detail = self.detail(context, right);
861                for (row, line) in list.into_iter().enumerate() {
862                    let mut line = pad(line, left);
863                    line.push(text(" │ ", &theme.border));
864                    if let Some(detail) = detail.get(row) {
865                        line.extend(fit(detail.clone(), right));
866                    }
867                    lines.push(line);
868                }
869                lines.push(footer);
870            }
871            PreviewLayout::Below => {
872                lines.extend(list);
873                lines.push(footer);
874                lines.push(vec![text("─".repeat(width), &theme.border)]);
875                lines.extend(
876                    self.detail(context, width)
877                        .into_iter()
878                        .take(self.preview_height),
879                );
880            }
881            _ => {
882                lines.extend(list);
883                lines.push(footer);
884            }
885        }
886        let view = View::new(lines);
887        match caret {
888            Some(column) => view.with_cursor(1 + rows, column),
889            None => view,
890        }
891    }
892
893    /// Ask without a terminal, item by item.
894    fn ask(&mut self, io: &mut dyn LineIo) -> Result<Option<Vec<usize>>, NotInteractive> {
895        io.write(&format!("{}\n", self.prompt));
896        let total = self.items.len();
897        for index in 0..total {
898            if self.verdicts[index] != Verdict::Pending {
899                continue;
900            }
901            let item = &self.items[index];
902            let mut about = format!("[{}/{total}] {}", index + 1, item.label);
903            if let Some(description) = &item.description {
904                about.push_str(&format!(" — {description}"));
905            }
906            io.write(&format!("{about}\n"));
907            for (key, value) in &item.metadata {
908                io.write(&format!("  {key}: {value}\n"));
909            }
910            loop {
911                io.write("Approve, reject or skip? [a/r/s, a reason after a colon, q stops]: ");
912                let Some(line) = io.read_line() else {
913                    return Ok(Some((0..total).collect()));
914                };
915                match parse_answer(&line) {
916                    Ok(None) => return Ok(Some((0..total).collect())),
917                    Ok(Some((verdict, reason))) => {
918                        if reason.is_none() && self.required.contains(&verdict) {
919                            io.write(&format!("A reason is needed to {}.\n", verdict.verb()));
920                            continue;
921                        }
922                        self.verdicts[index] = verdict;
923                        self.reasons[index] = reason;
924                        break;
925                    }
926                    Err(message) => io.write(&format!("{message}\n")),
927                }
928            }
929        }
930        Ok(Some((0..total).collect()))
931    }
932}
933
934/// A line-prompt answer: `a`, `r`, `s` (or `approve`, `yes`, `no`…), with
935/// an optional reason after a colon. `Ok(None)` stops the review (`q`); an
936/// empty line skips.
937pub(crate) fn parse_answer(line: &str) -> Result<Option<(Verdict, Option<String>)>, String> {
938    let (word, reason) = match line.split_once(':') {
939        Some((word, reason)) => (word, Some(reason.trim().to_string())),
940        None => (line, None),
941    };
942    let reason = reason.filter(|reason| !reason.is_empty());
943    let verdict = match word.trim().to_lowercase().as_str() {
944        "a" | "approve" | "approved" | "y" | "yes" => Verdict::Approved,
945        "r" | "reject" | "rejected" | "n" | "no" => Verdict::Rejected,
946        "s" | "skip" | "skipped" | "" => Verdict::Skipped,
947        "q" | "quit" => return Ok(None),
948        other => return Err(format!("{other:?} is not a, r, s or q")),
949    };
950    Ok(Some((verdict, reason)))
951}
952
953impl<T: Clone> ApprovalQueue<T> {
954    /// The report as things stand: every item with its decision so far.
955    pub fn report(&self) -> ApprovalReport<T> {
956        ApprovalReport {
957            decisions: self
958                .items
959                .iter()
960                .enumerate()
961                .map(|(index, item)| Decision {
962                    index,
963                    value: item.value.clone(),
964                    label: item.label.clone(),
965                    verdict: self.verdicts[index],
966                    reason: self.reasons[index].clone(),
967                })
968                .collect(),
969        }
970    }
971}
972
973impl<T: Clone> Component for ApprovalQueue<T> {
974    type Output = ApprovalReport<T>;
975
976    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<ApprovalReport<T>> {
977        match self.event(event, context) {
978            Flow::Done(_) => Flow::Done(self.report()),
979            Flow::Cancel => Flow::Cancel,
980            Flow::Ignored => Flow::Ignored,
981            _ => Flow::Continue,
982        }
983    }
984
985    fn render(&self, context: &Context<'_>) -> View {
986        self.render_view(context)
987    }
988
989    fn mouse(&self) -> bool {
990        self.mouse
991    }
992
993    fn keymap(&self) -> Keymap {
994        if self.reason.is_some() {
995            return Keymap::new("approve-reason")
996                .bind("confirm", keys("enter"), "decide with this reason")
997                .bind("back", keys("escape"), "back, deciding nothing");
998        }
999        self.keymap.clone()
1000    }
1001
1002    /// The decisions as they stand: without a terminal and asked for
1003    /// defaults, nothing is decided.
1004    fn default_value(&self) -> Option<ApprovalReport<T>> {
1005        Some(self.report())
1006    }
1007
1008    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<ApprovalReport<T>>, NotInteractive> {
1009        Ok(self.ask(io)?.map(|_| self.report()))
1010    }
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::*;
1016
1017    #[test]
1018    fn answers_parse_with_reasons() {
1019        assert_eq!(parse_answer("a"), Ok(Some((Verdict::Approved, None))));
1020        assert_eq!(
1021            parse_answer("r: not now "),
1022            Ok(Some((Verdict::Rejected, Some("not now".into()))))
1023        );
1024        assert_eq!(parse_answer(""), Ok(Some((Verdict::Skipped, None))));
1025        assert_eq!(parse_answer("YES:"), Ok(Some((Verdict::Approved, None))));
1026        assert_eq!(parse_answer("q"), Ok(None));
1027        assert!(parse_answer("maybe").is_err());
1028        assert_eq!(Verdict::parse("reject"), Some(Verdict::Rejected));
1029        assert_eq!(Verdict::Skipped.to_string(), "skipped");
1030    }
1031}