Skip to main content

rich_interact/components/
reorder.rs

1//! Putting a list in order (#720): move the focused item up and down with
2//! the keyboard or drag it with the mouse, then Enter returns the items in
3//! their new order.
4//!
5//! Keys (context `reorder`, rebindable): ↑/↓ (or `k`/`j`) move the focus;
6//! Alt+↑/↓, Ctrl+↑/↓ and `K`/`J` move the focused item one place, and
7//! Alt+Home/End (or Ctrl+Home/End) to the top or the bottom. Alt and Ctrl
8//! with an arrow need a terminal that reports modifiers on arrows (xterm's
9//! encoding, which most do, or the kitty keyboard protocol); `K`/`J` and
10//! grabbing work in any terminal: Space grabs the focused item, the arrows,
11//! PgUp/PgDn and Home/End then carry it, Space drops it and Esc puts it
12//! back where it was grabbed. Ctrl+R restores the first order.
13//!
14//! With the mouse on, a press on a row focuses it, and dragging carries it:
15//! the list moves under the pointer as it goes, and scrolls when the
16//! pointer passes the top or the bottom row.
17//!
18//! Without a terminal it lists the items numbered and reads the new order
19//! as numbers (`3 1 2`, or `3, 1`): those come first, in that order, and
20//! the rest keep theirs. An empty line keeps the order.
21
22use std::cell::Cell;
23
24use crate::component::{Component, Context, Flow, View};
25use crate::components::{Theme, fit, plain, question, text};
26use crate::event::{Button, Event, Key, MouseKind};
27use crate::item::Item;
28use crate::keymap::{Keymap, keys};
29use crate::policy::{LineIo, NotInteractive};
30
31/// The keys of a [`Reorder`], in context `reorder`.
32pub fn reorder_keymap() -> Keymap {
33    Keymap::new("reorder")
34        .bind("pick", keys("enter"), "done: return the new order")
35        .bind(
36            "cancel",
37            keys("escape"),
38            "cancel (while carrying: put it back)",
39        )
40        .bind("up", keys("up k ctrl+p"), "focus up")
41        .bind("down", keys("down j ctrl+n"), "focus down")
42        .bind("page-up", keys("pageup"), "page up")
43        .bind("page-down", keys("pagedown"), "page down")
44        .bind("first", keys("home"), "first")
45        .bind("last", keys("end"), "last")
46        .bind("move-up", keys("alt+up ctrl+up K"), "move the item up")
47        .bind(
48            "move-down",
49            keys("alt+down ctrl+down J"),
50            "move the item down",
51        )
52        .bind(
53            "move-first",
54            keys("alt+home ctrl+home"),
55            "move the item to the top",
56        )
57        .bind(
58            "move-last",
59            keys("alt+end ctrl+end"),
60            "move the item to the bottom",
61        )
62        .bind("grab", keys("space"), "grab or drop the item")
63        .bind("reset", keys("ctrl+r"), "restore the first order")
64}
65
66/// Move the element at `from` to `to`, shifting the ones between: how a
67/// [`Reorder`] applies each move, for a caller keeping its own copy.
68///
69/// ```
70/// let mut items = vec!["a", "b", "c", "d"];
71/// rich_interact::components::move_item(&mut items, 0, 2);
72/// assert_eq!(items, ["b", "c", "a", "d"]);
73/// ```
74pub fn move_item<T>(items: &mut [T], from: usize, to: usize) {
75    if from < to {
76        items[from..=to].rotate_left(1);
77    } else if to < from {
78        items[to..=from].rotate_right(1);
79    }
80}
81
82/// A list put in order by the user. Returns the items' values in the new
83/// order; [`order`](Self::order) has it as the items' first indices.
84///
85/// ```
86/// use rich_interact::headless::{self, Script};
87/// use rich_interact::{Outcome, Reorder};
88///
89/// let reorder = Reorder::new("Priority", ["alpha", "beta", "gamma"]);
90/// // Focus gamma, carry it to the top, done.
91/// let script = Script::new().keys("down down alt+up alt+up enter");
92/// let (outcome, _) = headless::run(reorder, script, 40, 8);
93/// assert_eq!(outcome.unwrap(), Outcome::Done(vec!["gamma", "alpha", "beta"]));
94/// ```
95#[must_use = "a component does nothing until it is run or mounted"]
96pub struct Reorder<T> {
97    prompt: String,
98    items: Vec<Item<T>>,
99    /// The items' first indices, in the current order.
100    order: Vec<usize>,
101    cursor: usize,
102    offset: usize,
103    height: usize,
104    theme: Theme,
105    mouse: bool,
106    /// Grabbed with Space: where the item was when it was grabbed.
107    grabbed: Option<usize>,
108    /// A drag with the mouse in progress.
109    dragging: bool,
110    keymap: Keymap,
111    /// Once finished: the answer shown in place of the list (`None`
112    /// inside means cancelled).
113    answer: Option<Option<String>>,
114    /// Rows of items on screen, from the last render.
115    page: Cell<usize>,
116}
117
118impl<T> Reorder<T> {
119    pub fn new<I, V>(prompt: impl Into<String>, items: I) -> Reorder<T>
120    where
121        I: IntoIterator<Item = V>,
122        V: Into<Item<T>>,
123    {
124        let items: Vec<Item<T>> = items.into_iter().map(Into::into).collect();
125        Reorder {
126            prompt: prompt.into(),
127            order: (0..items.len()).collect(),
128            items,
129            cursor: 0,
130            offset: 0,
131            height: 10,
132            theme: Theme::default(),
133            mouse: false,
134            grabbed: None,
135            dragging: false,
136            keymap: reorder_keymap(),
137            answer: None,
138            page: Cell::new(10),
139        }
140    }
141
142    /// Show at most `rows` items at once (default 10).
143    pub fn height(mut self, rows: usize) -> Self {
144        self.height = rows.max(1);
145        self
146    }
147
148    pub fn theme(mut self, theme: Theme) -> Self {
149        self.theme = theme;
150        self
151    }
152
153    /// Focus this position first.
154    pub fn focus(mut self, position: usize) -> Self {
155        self.cursor = position.min(self.items.len().saturating_sub(1));
156        self
157    }
158
159    /// Report the mouse (#476): a press focuses a row and a drag carries
160    /// it.
161    pub fn with_mouse(mut self, on: bool) -> Self {
162        self.mouse = on;
163        self
164    }
165
166    /// Make `keys` do `action` (see [`reorder_keymap`]) on this list only;
167    /// no keys unbinds it.
168    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
169        self.keymap.rebind(action, keys);
170        self
171    }
172
173    pub fn items(&self) -> &[Item<T>] {
174        &self.items
175    }
176
177    /// The order so far, as the items' indices when the list was made:
178    /// `[2, 0, 1]` once the third item was moved to the top.
179    pub fn order(&self) -> &[usize] {
180        &self.order
181    }
182
183    /// The focused position.
184    pub fn cursor(&self) -> usize {
185        self.cursor
186    }
187
188    /// Whether an item is grabbed (Space) and moves with the arrows.
189    pub fn is_grabbed(&self) -> bool {
190        self.grabbed.is_some()
191    }
192
193    fn follow(&mut self) {
194        self.offset = self.offset_for(self.page.get());
195    }
196
197    /// The first position shown on a page of `rows`: the offset as the
198    /// last event left it, kept on the focused row and the page full in
199    /// case the terminal changed size since (or `focus` moved it before
200    /// the first paint). Drawing and the mouse both use it.
201    fn offset_for(&self, rows: usize) -> usize {
202        let rows = rows.max(1);
203        let offset = self.offset.min(self.order.len().saturating_sub(rows));
204        if self.cursor < offset {
205            self.cursor
206        } else if self.cursor >= offset + rows {
207            self.cursor + 1 - rows
208        } else {
209            offset
210        }
211    }
212
213    /// Move the focus to `position`, clamped to the list.
214    fn focus_on(&mut self, position: usize) {
215        self.cursor = position.min(self.order.len().saturating_sub(1));
216        self.follow();
217    }
218
219    /// Carry the focused item to `position`, clamped to the list.
220    fn carry_to(&mut self, position: usize) {
221        if self.order.is_empty() {
222            return;
223        }
224        let to = position.min(self.order.len() - 1);
225        move_item(&mut self.order, self.cursor, to);
226        self.focus_on(to);
227    }
228
229    fn by(&self, delta: isize) -> usize {
230        self.cursor.saturating_add_signed(delta)
231    }
232
233    fn finish(&mut self) -> Flow<Vec<usize>> {
234        let labels: Vec<&str> = self
235            .order
236            .iter()
237            .map(|&index| self.items[index].label.as_str())
238            .collect();
239        self.answer = Some(Some(labels.join(", ")));
240        self.grabbed = None;
241        Flow::Done(self.order.clone())
242    }
243
244    fn act(&mut self, action: &str) -> Flow<Vec<usize>> {
245        let page = self.page.get().max(1) as isize;
246        let carrying = self.grabbed.is_some();
247        // While an item is grabbed, the moves carry it.
248        let (target, carry) = match action {
249            "up" => (self.by(-1), carrying),
250            "down" => (self.by(1), carrying),
251            "page-up" => (self.by(-page), carrying),
252            "page-down" => (self.by(page), carrying),
253            "first" => (0, carrying),
254            "last" => (usize::MAX, carrying),
255            "move-up" => (self.by(-1), true),
256            "move-down" => (self.by(1), true),
257            "move-first" => (0, true),
258            "move-last" => (usize::MAX, true),
259            "pick" => return self.finish(),
260            "cancel" => {
261                if let Some(from) = self.grabbed.take() {
262                    self.carry_to(from);
263                    return Flow::Continue;
264                }
265                self.answer = Some(None);
266                return Flow::Cancel;
267            }
268            "grab" => {
269                self.grabbed = match self.grabbed {
270                    Some(_) => None,
271                    None if !self.order.is_empty() => Some(self.cursor),
272                    None => None,
273                };
274                return Flow::Continue;
275            }
276            "reset" => {
277                // The focused item stays focused, wherever it lands.
278                let focused = self.order.get(self.cursor).copied();
279                self.order = (0..self.items.len()).collect();
280                self.grabbed = None;
281                if let Some(index) = focused {
282                    self.focus_on(index);
283                }
284                return Flow::Continue;
285            }
286            _ => return Flow::Ignored,
287        };
288        if carry {
289            self.carry_to(target);
290        } else {
291            self.focus_on(target);
292        }
293        Flow::Continue
294    }
295
296    fn mouse_event(&mut self, kind: MouseKind, row: usize) -> Flow<Vec<usize>> {
297        let page = self.page.get().max(1);
298        // The list starts under the question.
299        let top = 1;
300        let shown = self.order.len().saturating_sub(self.offset).min(page);
301        match kind {
302            // The wheel is ↑ and ↓: while an item is grabbed, it carries it.
303            MouseKind::ScrollUp => return self.act("up"),
304            MouseKind::ScrollDown => return self.act("down"),
305            MouseKind::Down(Button::Left) if row >= top && row < top + shown => {
306                self.grabbed = None;
307                self.focus_on(self.offset + row - top);
308                self.dragging = true;
309            }
310            MouseKind::Drag(Button::Left) if self.dragging => {
311                // Past the top or the bottom row, the list scrolls a row at
312                // a time under the item.
313                let position = if row < top {
314                    self.offset.saturating_sub(1)
315                } else if row >= top + shown {
316                    self.offset + shown
317                } else {
318                    self.offset + row - top
319                };
320                self.carry_to(position);
321            }
322            MouseKind::Up(_) => self.dragging = false,
323            _ => return Flow::Ignored,
324        }
325        Flow::Continue
326    }
327
328    fn event(&mut self, event: &Event, context: &Context<'_>) -> Flow<Vec<usize>> {
329        self.page.set(self.rows(context.height));
330        // The rows as drawn, for a click to land on the one under it.
331        self.follow();
332        if let Some(mouse) = event.mouse() {
333            return self.mouse_event(mouse.kind, mouse.row as usize);
334        }
335        let Some(key) = event.key() else {
336            return Flow::Ignored;
337        };
338        match self.keymap.action(key).map(str::to_string) {
339            Some(action) => self.act(&action),
340            None => Flow::Ignored,
341        }
342    }
343
344    /// Rows for the items on a terminal `height` rows tall: the question
345    /// and the footer stay.
346    fn rows(&self, height: usize) -> usize {
347        self.height
348            .min(self.order.len().max(1))
349            .min(height.saturating_sub(2).max(1))
350    }
351
352    fn render_view(&self, context: &Context<'_>) -> View {
353        let width = context.width;
354        let theme = &self.theme;
355        let mut header = question(theme, &self.prompt);
356        if let Some(answer) = &self.answer {
357            header.push(match answer {
358                Some(answer) => text(answer.clone(), &theme.answer),
359                None => text("cancelled", &theme.hint),
360            });
361            return View::new(vec![fit(header, width)]);
362        }
363        let rows = self.rows(context.height);
364        self.page.set(rows);
365        let mut lines = vec![fit(header, width)];
366        let offset = self.offset_for(rows);
367        let reverse = rich::Style::parse("reverse").expect("a built-in style");
368        for (position, &index) in self.order.iter().enumerate().skip(offset).take(rows) {
369            let item = &self.items[index];
370            let focused = position == self.cursor;
371            let carried = focused && (self.grabbed.is_some() || self.dragging);
372            let mut line = if carried {
373                // A mark of its own, so it shows without colour.
374                vec![text("↕ ", &theme.pointer_style)]
375            } else if focused {
376                vec![text(format!("{} ", theme.pointer), &theme.pointer_style)]
377            } else {
378                vec![plain("  ")]
379            };
380            line.push(if carried {
381                text(item.label.clone(), &theme.focused.combine(&reverse))
382            } else if focused {
383                text(item.label.clone(), &theme.focused)
384            } else {
385                plain(item.label.clone())
386            });
387            if let Some(description) = &item.description {
388                line.push(text(format!("  {description}"), &theme.hint));
389            }
390            lines.push(fit(line, width));
391        }
392        if self.order.is_empty() {
393            lines.push(vec![text("  nothing to order", &theme.hint)]);
394        }
395        let hint = match self.grabbed {
396            Some(_) => {
397                let label = &self.items[self.order[self.cursor]].label;
398                format!(
399                    "  moving {label} ({}/{}) · ↑↓ move · space drop · esc put back · enter done",
400                    self.cursor + 1,
401                    self.order.len()
402                )
403            }
404            None => format!(
405                "  {}/{} · ↑↓ focus · alt+↑↓ or K/J move · space grab · enter done · esc cancel",
406                (self.cursor + 1).min(self.order.len()),
407                self.order.len()
408            ),
409        };
410        lines.push(fit(vec![text(hint, &theme.hint)], width));
411        View::new(lines)
412    }
413
414    /// Ask without a terminal: the items numbered, then the new order as
415    /// numbers.
416    fn ask(&mut self, io: &mut dyn LineIo) -> Result<Option<Vec<usize>>, NotInteractive> {
417        io.write(&format!("{}\n", self.prompt));
418        for (number, &index) in self.order.iter().enumerate() {
419            let item = &self.items[index];
420            let mut row = format!("  {}) {}", number + 1, item.label);
421            if let Some(description) = &item.description {
422                row.push_str(&format!(" — {description}"));
423            }
424            io.write(&format!("{row}\n"));
425        }
426        io.write("New order, as numbers (the rest keep theirs; empty keeps it): ");
427        // At the end of input, or on an empty line, the order stands.
428        let Some(line) = io.read_line() else {
429            return Ok(Some(self.order.clone()));
430        };
431        let order = parse_order(&line, self.order.len()).map_err(NotInteractive::Invalid)?;
432        self.order = order.into_iter().map(|at| self.order[at]).collect();
433        Ok(Some(self.order.clone()))
434    }
435}
436
437/// Read a new order of `len` items from `line`: positions from 1,
438/// separated by spaces or commas. Those named come first, in that order,
439/// and the rest keep theirs. Returns positions from 0.
440pub(crate) fn parse_order(line: &str, len: usize) -> Result<Vec<usize>, String> {
441    let mut named = Vec::new();
442    // Which positions are named, so a long list is read in one pass.
443    let mut used = vec![false; len];
444    for word in line
445        .split(|c: char| c == ',' || c.is_whitespace())
446        .filter(|word| !word.is_empty())
447    {
448        let number = word
449            .parse::<usize>()
450            .ok()
451            .filter(|number| (1..=len).contains(number))
452            .ok_or_else(|| format!("{word:?} is not a number from 1 to {len}"))?;
453        if std::mem::replace(&mut used[number - 1], true) {
454            return Err(format!("{number} is given twice"));
455        }
456        named.push(number - 1);
457    }
458    named.extend((0..len).filter(|&at| !used[at]));
459    Ok(named)
460}
461
462impl<T: Clone> Reorder<T> {
463    fn values(&self, order: &[usize]) -> Vec<T> {
464        order
465            .iter()
466            .map(|&index| self.items[index].value.clone())
467            .collect()
468    }
469}
470
471impl<T: Clone> Component for Reorder<T> {
472    type Output = Vec<T>;
473
474    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<Vec<T>> {
475        match self.event(event, context) {
476            Flow::Done(order) => Flow::Done(self.values(&order)),
477            Flow::Cancel => Flow::Cancel,
478            Flow::Ignored => Flow::Ignored,
479            _ => Flow::Continue,
480        }
481    }
482
483    fn render(&self, context: &Context<'_>) -> View {
484        self.render_view(context)
485    }
486
487    fn mouse(&self) -> bool {
488        self.mouse
489    }
490
491    fn keymap(&self) -> Keymap {
492        self.keymap.clone()
493    }
494
495    /// The order as it stands: without a terminal and asked for defaults,
496    /// nothing moves.
497    fn default_value(&self) -> Option<Vec<T>> {
498        Some(self.values(&self.order))
499    }
500
501    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<Vec<T>>, NotInteractive> {
502        Ok(self.ask(io)?.map(|order| self.values(&order)))
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn moves_shift_the_items_between() {
512        let mut items = [0, 1, 2, 3, 4];
513        move_item(&mut items, 4, 1);
514        assert_eq!(items, [0, 4, 1, 2, 3]);
515        move_item(&mut items, 1, 4);
516        assert_eq!(items, [0, 1, 2, 3, 4]);
517        move_item(&mut items, 2, 2);
518        assert_eq!(items, [0, 1, 2, 3, 4]);
519    }
520
521    #[test]
522    fn orders_parse_from_numbers() {
523        assert_eq!(parse_order("3 1 2", 3), Ok(vec![2, 0, 1]));
524        assert_eq!(parse_order("3,1", 4), Ok(vec![2, 0, 1, 3]));
525        assert_eq!(parse_order("", 3), Ok(vec![0, 1, 2]));
526        assert_eq!(parse_order(" 2 ,, ", 2), Ok(vec![1, 0]));
527        assert!(parse_order("4", 3).is_err());
528        assert!(parse_order("0", 3).is_err());
529        assert!(parse_order("1 1", 3).is_err());
530        assert!(parse_order("one", 3).is_err());
531    }
532}