Skip to main content

rich_interact/components/
paginator.rs

1//! A paginator (0.0.21): which page of several is showing, as dots
2//! (`● ○ ○`) or a fraction (`3/10`), moved with the arrow keys, Page Up
3//! and Page Down, Home and End, the wheel, or a click on a dot.
4//!
5//! It is a small piece of state with a one-line indicator, to embed
6//! anywhere a long list or a multi-page form needs one ([`Form`]'s groups
7//! use it), and a [`Component`] of its own: on its own it shows a list of
8//! items a page at a time, and Enter returns the page shown.
9//!
10//! [`Form`]: crate::Form
11
12use std::ops::Range;
13
14use rich::Segment;
15
16use crate::component::{Component, Context, Flow, View};
17use crate::components::{Theme, fit, plain, question, text};
18use crate::event::{Event, Key, MouseKind};
19use crate::keymap::{Keymap, keys};
20use crate::policy::{LineIo, NotInteractive};
21
22/// How a [`Paginator`] shows the page.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum PaginatorStyle {
25    /// A dot a page, the current one filled: `○ ● ○`. Falls back to
26    /// [`Fraction`](PaginatorStyle::Fraction) when the dots do not fit.
27    #[default]
28    Dots,
29    /// The page and the count: `2/3`.
30    Fraction,
31}
32
33/// A paginator's keys: Left and Right (and `h`, `l`, Page Up, Page Down)
34/// move a page, Home and End go to the ends; standalone, Enter picks the
35/// page and Escape cancels.
36pub fn paginator_keymap() -> Keymap {
37    Keymap::new("paginator")
38        .bind("next-page", keys("right l pagedown"), "next page")
39        .bind("previous-page", keys("left h pageup"), "previous page")
40        .bind("first-page", keys("home"), "first page")
41        .bind("last-page", keys("end"), "last page")
42        .bind("choose", keys("enter"), "choose this page")
43        .bind("cancel", keys("escape q"), "cancel")
44}
45
46/// Which page of several is showing.
47///
48/// ```
49/// use rich_interact::headless::{self, Script};
50/// use rich_interact::{Paginator, PaginatorStyle};
51///
52/// // Embedded: state and an indicator line.
53/// let mut pages = Paginator::new(3);
54/// assert!(pages.next_page());
55/// assert_eq!(pages.page(), 1);
56/// assert_eq!(pages.indicator_text(80), "○ ● ○");
57/// assert_eq!(pages.style(PaginatorStyle::Fraction).indicator_text(80), "2/3");
58///
59/// // Standalone: a list shown a page at a time; Enter returns the page.
60/// let letters = ["a", "b", "c", "d", "e"];
61/// let paginator = Paginator::over("Letters", letters, 2);
62/// let (outcome, record) = headless::run(paginator, Script::new().keys("right enter"), 40, 8);
63/// assert_eq!(outcome.unwrap().value(), Some(1));
64/// assert_eq!(record.last_frame(), "? Letters › page 2 of 3");
65/// ```
66#[must_use = "a component does nothing until it is run or mounted"]
67pub struct Paginator {
68    pages: usize,
69    page: usize,
70    style: PaginatorStyle,
71    active: String,
72    inactive: String,
73    title: Option<String>,
74    items: Vec<String>,
75    per_page: usize,
76    theme: Theme,
77    mouse: bool,
78    rebound: Vec<(String, Vec<Key>)>,
79    /// `Some(true)` once a page was chosen, `Some(false)` once cancelled.
80    answer: Option<bool>,
81}
82
83impl Paginator {
84    /// A paginator over `pages` pages (at least one), on the first.
85    pub fn new(pages: usize) -> Paginator {
86        Paginator {
87            pages: pages.max(1),
88            page: 0,
89            style: PaginatorStyle::Dots,
90            active: "●".into(),
91            inactive: "○".into(),
92            title: None,
93            items: Vec::new(),
94            per_page: 1,
95            theme: Theme::default(),
96            mouse: false,
97            rebound: Vec::new(),
98            answer: None,
99        }
100    }
101
102    /// A paginator over `items`, `per_page` at a time, titled `title`: the
103    /// standalone component shows the current page's items above the
104    /// indicator.
105    pub fn over<I, S>(title: impl Into<String>, items: I, per_page: usize) -> Paginator
106    where
107        I: IntoIterator<Item = S>,
108        S: Into<String>,
109    {
110        let items: Vec<String> = items.into_iter().map(Into::into).collect();
111        let per_page = per_page.max(1);
112        let mut paginator = Paginator::new(items.len().div_ceil(per_page));
113        paginator.title = Some(title.into());
114        paginator.items = items;
115        paginator.per_page = per_page;
116        paginator
117    }
118
119    /// Dots or a fraction (default: dots).
120    pub fn style(mut self, style: PaginatorStyle) -> Self {
121        self.style = style;
122        self
123    }
124
125    /// The dots for the current page and the others (default `●` and `○`).
126    pub fn dots(mut self, active: impl Into<String>, inactive: impl Into<String>) -> Self {
127        self.active = active.into();
128        self.inactive = inactive.into();
129        self
130    }
131
132    /// Start on `page` (0-based; past the last means the last).
133    pub fn start_at(mut self, page: usize) -> Self {
134        self.set_page(page);
135        self
136    }
137
138    /// The current dot is drawn in the theme's `checked_style`, the others
139    /// and the fraction's count in `hint`.
140    pub fn theme(mut self, theme: Theme) -> Self {
141        self.theme = theme;
142        self
143    }
144
145    /// Report the mouse: a click on a dot goes to its page, and the wheel
146    /// moves a page.
147    pub fn with_mouse(mut self, on: bool) -> Self {
148        self.mouse = on;
149        self
150    }
151
152    /// Make `keys` do `action` on this paginator only (see
153    /// [`paginator_keymap`]); no keys unbinds it.
154    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
155        self.rebound
156            .push((action.to_string(), keys.into_iter().collect()));
157        self
158    }
159
160    /// The number of pages.
161    pub fn pages(&self) -> usize {
162        self.pages
163    }
164
165    /// The current page, from 0.
166    pub fn page(&self) -> usize {
167        self.page
168    }
169
170    /// Change the number of pages (at least one), keeping the current page
171    /// if it is still there.
172    pub fn set_pages(&mut self, pages: usize) {
173        self.pages = pages.max(1);
174        self.page = self.page.min(self.pages - 1);
175    }
176
177    /// Go to `page`; past the last means the last.
178    pub fn set_page(&mut self, page: usize) {
179        self.page = page.min(self.pages - 1);
180    }
181
182    /// Move to the next page; `false` on the last.
183    pub fn next_page(&mut self) -> bool {
184        let moved = !self.on_last();
185        self.page = (self.page + 1).min(self.pages - 1);
186        moved
187    }
188
189    /// Move to the previous page; `false` on the first.
190    pub fn previous_page(&mut self) -> bool {
191        let moved = !self.on_first();
192        self.page = self.page.saturating_sub(1);
193        moved
194    }
195
196    pub fn on_first(&self) -> bool {
197        self.page == 0
198    }
199
200    pub fn on_last(&self) -> bool {
201        self.page + 1 == self.pages
202    }
203
204    /// The range of a list of `len` items on the current page, at
205    /// `per_page` a page: what to slice for it.
206    pub fn bounds(&self, len: usize, per_page: usize) -> Range<usize> {
207        let start = (self.page * per_page.max(1)).min(len);
208        start..(start + per_page.max(1)).min(len)
209    }
210
211    /// The keys, with this paginator's rebinding.
212    pub fn keys(&self) -> Keymap {
213        let mut keymap = paginator_keymap();
214        for (action, keys) in &self.rebound {
215            keymap.rebind(action, keys.clone());
216        }
217        keymap
218    }
219
220    /// Move for `key` if it is one of the moving keys (next, previous,
221    /// first, last page): `true` when it was, whether or not the page
222    /// changed. For a container that embeds a paginator.
223    pub fn handle_key(&mut self, key: Key) -> bool {
224        match self.keys().action(key) {
225            Some("next-page") => self.next_page(),
226            Some("previous-page") => self.previous_page(),
227            Some("first-page") => {
228                self.page = 0;
229                true
230            }
231            Some("last-page") => {
232                self.page = self.pages - 1;
233                true
234            }
235            _ => return false,
236        };
237        true
238    }
239
240    /// Whether the dots of every page fit in `width` columns.
241    fn dots_fit(&self, width: usize) -> bool {
242        let dot = rich::cells::cell_len(&self.active).max(rich::cells::cell_len(&self.inactive));
243        self.pages * (dot + 1) <= width + 1
244    }
245
246    fn uses_dots(&self, width: usize) -> bool {
247        self.style == PaginatorStyle::Dots && self.dots_fit(width)
248    }
249
250    /// The indicator line, at most `width` columns.
251    pub fn indicator(&self, width: usize) -> Vec<Segment> {
252        if self.uses_dots(width) {
253            let mut line = Vec::new();
254            for page in 0..self.pages {
255                if page > 0 {
256                    line.push(plain(" "));
257                }
258                line.push(if page == self.page {
259                    text(self.active.clone(), &self.theme.checked_style)
260                } else {
261                    text(self.inactive.clone(), &self.theme.hint)
262                });
263            }
264            fit(line, width)
265        } else {
266            fit(
267                vec![
268                    text(format!("{}", self.page + 1), &self.theme.focused),
269                    text(format!("/{}", self.pages), &self.theme.hint),
270                ],
271                width,
272            )
273        }
274    }
275
276    /// The indicator as plain text: what a screen reader or a log reads.
277    pub fn indicator_text(&self, width: usize) -> String {
278        self.indicator(width)
279            .iter()
280            .map(|segment| segment.text.as_str())
281            .collect()
282    }
283
284    /// The page a click at `column` of the indicator (drawn at `width`)
285    /// lands on: a dot's page, or `None` off the dots.
286    pub fn page_at(&self, width: usize, column: usize) -> Option<usize> {
287        if !self.uses_dots(width) {
288            return None;
289        }
290        let mut at = 0;
291        for page in 0..self.pages {
292            let dot = if page == self.page {
293                &self.active
294            } else {
295                &self.inactive
296            };
297            let end = at + rich::cells::cell_len(dot);
298            if (at..end).contains(&column) {
299                return Some(page);
300            }
301            at = end + 1;
302        }
303        None
304    }
305
306    /// Where the standalone view's indicator row is.
307    fn indicator_row(&self) -> usize {
308        usize::from(self.title.is_some()) + self.bounds(self.items.len(), self.per_page).len()
309    }
310}
311
312impl Component for Paginator {
313    type Output = usize;
314
315    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<usize> {
316        if let Some(mouse) = event.mouse() {
317            match mouse.kind {
318                MouseKind::ScrollDown => {
319                    self.next_page();
320                }
321                MouseKind::ScrollUp => {
322                    self.previous_page();
323                }
324                _ if mouse.is_click() && mouse.row as usize == self.indicator_row() => {
325                    let width = context.width.saturating_sub(2);
326                    if let Some(page) = (mouse.column as usize)
327                        .checked_sub(2)
328                        .and_then(|column| self.page_at(width, column))
329                    {
330                        self.page = page;
331                    }
332                }
333                _ => {}
334            }
335            return Flow::Continue;
336        }
337        let Some(key) = event.key() else {
338            return Flow::Continue;
339        };
340        if self.handle_key(key) {
341            return Flow::Continue;
342        }
343        match self.keys().action(key) {
344            Some("choose") => {
345                self.answer = Some(true);
346                Flow::Done(self.page)
347            }
348            Some("cancel") => {
349                self.answer = Some(false);
350                Flow::Cancel
351            }
352            _ => Flow::Ignored,
353        }
354    }
355
356    fn render(&self, context: &Context<'_>) -> View {
357        let width = context.width;
358        let theme = &self.theme;
359        let mut lines = Vec::new();
360        let mut title = self.title.as_ref().map(|title| {
361            let mut line = question(theme, title);
362            line.truncate(line.len() - 3);
363            line
364        });
365        match (self.answer, title.as_mut()) {
366            (Some(chosen), Some(line)) => {
367                let answer = if chosen {
368                    format!("page {} of {}", self.page + 1, self.pages)
369                } else {
370                    "cancelled".into()
371                };
372                line.push(text(" › ", &theme.hint));
373                line.push(text(
374                    answer,
375                    if chosen { &theme.answer } else { &theme.hint },
376                ));
377                return View::new(vec![fit(line.clone(), width)]);
378            }
379            (_, Some(line)) => lines.push(fit(line.clone(), width)),
380            (_, None) => {}
381        }
382        for item in &self.items[self.bounds(self.items.len(), self.per_page)] {
383            lines.push(fit(vec![plain("  "), plain(item.clone())], width));
384        }
385        let mut indicator = vec![plain("  ")];
386        indicator.extend(self.indicator(width.saturating_sub(2)));
387        lines.push(indicator);
388        lines.push(fit(
389            vec![text("  ←/→ page · enter choose · esc cancel", &theme.hint)],
390            width,
391        ));
392        View::new(lines)
393    }
394
395    fn keymap(&self) -> Keymap {
396        self.keys()
397    }
398
399    fn mouse(&self) -> bool {
400        self.mouse
401    }
402
403    fn default_value(&self) -> Option<usize> {
404        Some(self.page)
405    }
406
407    /// Without a terminal: every item, then the page asked for by number.
408    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<usize>, NotInteractive> {
409        if let Some(title) = &self.title {
410            io.write(&format!("{title}\n"));
411        }
412        for item in &self.items {
413            io.write(&format!("  {item}\n"));
414        }
415        io.write(&format!("Page (1-{}) [{}]: ", self.pages, self.page + 1));
416        let line = io.read_line().unwrap_or_default();
417        let line = line.trim();
418        if line.is_empty() {
419            return Ok(Some(self.page));
420        }
421        match line.parse::<usize>() {
422            Ok(page) if (1..=self.pages).contains(&page) => {
423                self.page = page - 1;
424                Ok(Some(self.page))
425            }
426            _ => Err(NotInteractive::Invalid(format!(
427                "not a page from 1 to {}: {line:?}",
428                self.pages
429            ))),
430        }
431    }
432}