rich_interact/components/
paginator.rs1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum PaginatorStyle {
25 #[default]
28 Dots,
29 Fraction,
31}
32
33pub 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#[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 answer: Option<bool>,
81}
82
83impl Paginator {
84 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 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 pub fn style(mut self, style: PaginatorStyle) -> Self {
121 self.style = style;
122 self
123 }
124
125 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 pub fn start_at(mut self, page: usize) -> Self {
134 self.set_page(page);
135 self
136 }
137
138 pub fn theme(mut self, theme: Theme) -> Self {
141 self.theme = theme;
142 self
143 }
144
145 pub fn with_mouse(mut self, on: bool) -> Self {
148 self.mouse = on;
149 self
150 }
151
152 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 pub fn pages(&self) -> usize {
162 self.pages
163 }
164
165 pub fn page(&self) -> usize {
167 self.page
168 }
169
170 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 pub fn set_page(&mut self, page: usize) {
179 self.page = page.min(self.pages - 1);
180 }
181
182 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 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 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 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 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 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 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 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 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 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 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}