Skip to main content

bubbles/
paginator.rs

1//! Pagination component for navigating through pages.
2//!
3//! This module provides pagination state management and display, useful for
4//! navigating lists, tables, or any paginated content.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::paginator::{Paginator, Type};
10//!
11//! let mut paginator = Paginator::new()
12//!     .per_page(10)
13//!     .total_pages(5);
14//!
15//! // Navigate
16//! paginator.next_page();
17//! assert_eq!(paginator.page(), 1);
18//!
19//! // Get slice bounds for rendering
20//! let items = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
21//! let (start, end) = paginator.get_slice_bounds(items.len());
22//! let visible = &items[start..end];
23//! ```
24
25use crate::key::{Binding, matches};
26use bubbletea::{Cmd, KeyMsg, Message, Model};
27
28/// Pagination display type.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum Type {
31    /// Arabic numerals: "1/5"
32    #[default]
33    Arabic,
34    /// Dot indicators: "●○○○○"
35    Dots,
36}
37
38/// Key bindings for pagination navigation.
39#[derive(Debug, Clone)]
40pub struct KeyMap {
41    /// Binding to go to previous page.
42    pub prev_page: Binding,
43    /// Binding to go to next page.
44    pub next_page: Binding,
45}
46
47impl Default for KeyMap {
48    fn default() -> Self {
49        Self {
50            prev_page: Binding::new()
51                .keys(&["pgup", "left", "h"])
52                .help("←/h", "prev page"),
53            next_page: Binding::new()
54                .keys(&["pgdown", "right", "l"])
55                .help("→/l", "next page"),
56        }
57    }
58}
59
60/// Pagination model.
61#[derive(Debug, Clone)]
62pub struct Paginator {
63    /// Display type (Arabic or Dots).
64    pub display_type: Type,
65    /// Current page (0-indexed).
66    page: usize,
67    /// Items per page.
68    per_page: usize,
69    /// Total number of pages.
70    total_pages: usize,
71    /// Character for active page in Dots mode.
72    pub active_dot: String,
73    /// Character for inactive pages in Dots mode.
74    pub inactive_dot: String,
75    /// Format string for Arabic mode.
76    pub arabic_format: String,
77    /// Key bindings.
78    pub key_map: KeyMap,
79}
80
81impl Default for Paginator {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl Paginator {
88    /// Creates a new paginator with default settings.
89    #[must_use]
90    pub fn new() -> Self {
91        Self {
92            display_type: Type::Arabic,
93            page: 0,
94            per_page: 1,
95            total_pages: 1,
96            active_dot: "•".to_string(),
97            inactive_dot: "○".to_string(),
98            arabic_format: "{}/{}".to_string(),
99            key_map: KeyMap::default(),
100        }
101    }
102
103    /// Sets the display type.
104    #[must_use]
105    pub fn display_type(mut self, t: Type) -> Self {
106        self.display_type = t;
107        self
108    }
109
110    /// Sets the number of items per page.
111    #[must_use]
112    pub fn per_page(mut self, n: usize) -> Self {
113        self.per_page = n.max(1);
114        self
115    }
116
117    /// Sets the total number of pages.
118    #[must_use]
119    pub fn total_pages(mut self, n: usize) -> Self {
120        self.total_pages = n.max(1);
121        self.page = self.page.min(self.total_pages.saturating_sub(1));
122        self
123    }
124
125    /// Returns the current page (0-indexed).
126    #[must_use]
127    pub fn page(&self) -> usize {
128        self.page
129    }
130
131    /// Sets the current page.
132    pub fn set_page(&mut self, page: usize) {
133        self.page = page.min(self.total_pages.saturating_sub(1));
134    }
135
136    /// Returns the items per page.
137    #[must_use]
138    pub fn get_per_page(&self) -> usize {
139        self.per_page
140    }
141
142    /// Returns the total number of pages.
143    #[must_use]
144    pub fn get_total_pages(&self) -> usize {
145        self.total_pages
146    }
147
148    /// Calculates and sets the total pages from item count.
149    ///
150    /// Returns the calculated total pages.
151    pub fn set_total_pages_from_items(&mut self, items: usize) -> usize {
152        if items < 1 {
153            self.total_pages = 1;
154            self.page = 0;
155            return self.total_pages;
156        }
157
158        let mut n = items / self.per_page;
159        if !items.is_multiple_of(self.per_page) {
160            n += 1;
161        }
162        self.total_pages = n;
163        self.page = self.page.min(self.total_pages.saturating_sub(1));
164        n
165    }
166
167    /// Returns the number of items on the current page.
168    #[must_use]
169    pub fn items_on_page(&self, total_items: usize) -> usize {
170        if total_items < 1 {
171            return 0;
172        }
173        let (start, end) = self.get_slice_bounds(total_items);
174        end - start
175    }
176
177    /// Returns slice bounds for the current page.
178    ///
179    /// Use this to get the start and end indices for slicing a collection.
180    ///
181    /// # Example
182    ///
183    /// ```rust
184    /// use bubbles::paginator::Paginator;
185    ///
186    /// let items = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
187    /// let mut paginator = Paginator::new().per_page(3);
188    /// paginator.set_total_pages_from_items(items.len());
189    ///
190    /// let (start, end) = paginator.get_slice_bounds(items.len());
191    /// assert_eq!(&items[start..end], &[1, 2, 3]);
192    /// ```
193    #[must_use]
194    pub fn get_slice_bounds(&self, length: usize) -> (usize, usize) {
195        let start = (self.page.saturating_mul(self.per_page)).min(length);
196        let end = (start.saturating_add(self.per_page)).min(length);
197        (start, end)
198    }
199
200    /// Navigates to the previous page.
201    pub fn prev_page(&mut self) {
202        if self.page > 0 {
203            self.page -= 1;
204        }
205    }
206
207    /// Navigates to the next page.
208    pub fn next_page(&mut self) {
209        if !self.on_last_page() {
210            self.page += 1;
211        }
212    }
213
214    /// Returns whether we're on the last page.
215    #[must_use]
216    pub fn on_last_page(&self) -> bool {
217        self.page == self.total_pages.saturating_sub(1)
218    }
219
220    /// Returns whether we're on the first page.
221    #[must_use]
222    pub fn on_first_page(&self) -> bool {
223        self.page == 0
224    }
225
226    /// Initializes the paginator.
227    ///
228    /// Paginators don't require initialization commands.
229    #[must_use]
230    pub fn init(&self) -> Option<Cmd> {
231        None
232    }
233
234    /// Updates the paginator based on key input.
235    pub fn update(&mut self, msg: Message) -> Option<Cmd> {
236        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
237            let key_str = key.to_string();
238            if matches(&key_str, &[&self.key_map.next_page]) {
239                self.next_page();
240            } else if matches(&key_str, &[&self.key_map.prev_page]) {
241                self.prev_page();
242            }
243        }
244        None
245    }
246
247    /// Renders the pagination display.
248    #[must_use]
249    pub fn view(&self) -> String {
250        match self.display_type {
251            Type::Dots => self.dots_view(),
252            Type::Arabic => self.arabic_view(),
253        }
254    }
255
256    fn dots_view(&self) -> String {
257        let mut s = String::new();
258        for i in 0..self.total_pages {
259            if i == self.page {
260                s.push_str(&self.active_dot);
261            } else {
262                s.push_str(&self.inactive_dot);
263            }
264        }
265        s
266    }
267
268    fn arabic_view(&self) -> String {
269        // Replace first {} with current page, second {} with total pages
270        self.arabic_format
271            .replacen("{}", &(self.page + 1).to_string(), 1)
272            .replacen("{}", &self.total_pages.to_string(), 1)
273    }
274}
275
276/// Implement the Model trait for standalone bubbletea usage.
277impl Model for Paginator {
278    fn init(&self) -> Option<Cmd> {
279        Paginator::init(self)
280    }
281
282    fn update(&mut self, msg: Message) -> Option<Cmd> {
283        Paginator::update(self, msg)
284    }
285
286    fn view(&self) -> String {
287        Paginator::view(self)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_paginator_new() {
297        let p = Paginator::new();
298        assert_eq!(p.page(), 0);
299        assert_eq!(p.get_per_page(), 1);
300        assert_eq!(p.get_total_pages(), 1);
301    }
302
303    #[test]
304    fn test_paginator_builder() {
305        let p = Paginator::new().per_page(10).total_pages(5);
306        assert_eq!(p.get_per_page(), 10);
307        assert_eq!(p.get_total_pages(), 5);
308    }
309
310    #[test]
311    fn test_paginator_navigation() {
312        let mut p = Paginator::new().total_pages(5);
313
314        assert!(p.on_first_page());
315        assert!(!p.on_last_page());
316
317        p.next_page();
318        assert_eq!(p.page(), 1);
319
320        p.next_page();
321        p.next_page();
322        p.next_page();
323        assert_eq!(p.page(), 4);
324        assert!(p.on_last_page());
325
326        // Should not go past last page
327        p.next_page();
328        assert_eq!(p.page(), 4);
329
330        p.prev_page();
331        assert_eq!(p.page(), 3);
332
333        // Go back to first
334        p.set_page(0);
335        assert!(p.on_first_page());
336
337        // Should not go before first page
338        p.prev_page();
339        assert_eq!(p.page(), 0);
340    }
341
342    #[test]
343    fn test_paginator_slice_bounds() {
344        let mut p = Paginator::new().per_page(3);
345        p.set_total_pages_from_items(10);
346
347        assert_eq!(p.get_slice_bounds(10), (0, 3));
348
349        p.next_page();
350        assert_eq!(p.get_slice_bounds(10), (3, 6));
351
352        p.next_page();
353        assert_eq!(p.get_slice_bounds(10), (6, 9));
354
355        p.next_page();
356        assert_eq!(p.get_slice_bounds(10), (9, 10));
357    }
358
359    #[test]
360    fn test_paginator_items_on_page() {
361        let mut p = Paginator::new().per_page(3);
362        p.set_total_pages_from_items(10);
363
364        assert_eq!(p.items_on_page(10), 3);
365
366        p.set_page(3); // Last page
367        assert_eq!(p.items_on_page(10), 1); // Only 1 item on last page
368    }
369
370    #[test]
371    fn test_paginator_arabic_view() {
372        let p = Paginator::new().total_pages(5);
373        assert_eq!(p.view(), "1/5");
374    }
375
376    #[test]
377    fn test_paginator_dots_view() {
378        let mut p = Paginator::new().display_type(Type::Dots).total_pages(5);
379        assert_eq!(p.view(), "•○○○○");
380
381        p.next_page();
382        assert_eq!(p.view(), "○•○○○");
383    }
384
385    #[test]
386    fn test_set_total_pages_from_items() {
387        let mut p = Paginator::new().per_page(10);
388
389        assert_eq!(p.set_total_pages_from_items(25), 3);
390        assert_eq!(p.get_total_pages(), 3);
391
392        assert_eq!(p.set_total_pages_from_items(20), 2);
393        assert_eq!(p.get_total_pages(), 2);
394
395        assert_eq!(p.set_total_pages_from_items(0), 1);
396        assert_eq!(p.get_total_pages(), 1);
397        assert_eq!(p.page(), 0);
398    }
399
400    #[test]
401    fn test_total_pages_clamps_current_page() {
402        let mut p = Paginator::new().total_pages(5);
403        p.set_page(4);
404        assert_eq!(p.page(), 4);
405
406        p = p.total_pages(1);
407        assert_eq!(p.page(), 0);
408    }
409
410    #[test]
411    fn test_slice_bounds_clamp_when_out_of_range() {
412        let mut p = Paginator::new().per_page(10).total_pages(5);
413        p.set_page(4);
414
415        let (start, end) = p.get_slice_bounds(5);
416        assert_eq!((start, end), (5, 5));
417    }
418
419    // Model trait tests
420
421    #[test]
422    fn test_paginator_model_init_returns_none() {
423        let p = Paginator::new().total_pages(5);
424        assert!(p.init().is_none());
425    }
426
427    #[test]
428    fn test_paginator_model_update_returns_none() {
429        use bubbletea::KeyType;
430        let mut p = Paginator::new().total_pages(5);
431        let result = p.update(Message::new(KeyMsg::from_type(KeyType::Right)));
432        assert!(result.is_none());
433    }
434
435    #[test]
436    fn test_paginator_model_update_next_key() {
437        use bubbletea::KeyType;
438
439        let mut p = Paginator::new().total_pages(5);
440        assert_eq!(p.page(), 0);
441
442        // Simulate right arrow key
443        let key_msg = KeyMsg::from_type(KeyType::Right);
444        p.update(Message::new(key_msg));
445        assert_eq!(p.page(), 1);
446
447        // Simulate 'l' key
448        let key_msg = KeyMsg::from_char('l');
449        p.update(Message::new(key_msg));
450        assert_eq!(p.page(), 2);
451    }
452
453    #[test]
454    fn test_paginator_model_update_prev_key() {
455        use bubbletea::KeyType;
456
457        let mut p = Paginator::new().total_pages(5);
458        p.set_page(3);
459        assert_eq!(p.page(), 3);
460
461        // Simulate left arrow key
462        let key_msg = KeyMsg::from_type(KeyType::Left);
463        p.update(Message::new(key_msg));
464        assert_eq!(p.page(), 2);
465
466        // Simulate 'h' key
467        let key_msg = KeyMsg::from_char('h');
468        p.update(Message::new(key_msg));
469        assert_eq!(p.page(), 1);
470    }
471
472    #[test]
473    fn test_paginator_model_view_first_page() {
474        let p = Paginator::new().total_pages(5);
475        assert_eq!(p.view(), "1/5");
476    }
477
478    #[test]
479    fn test_paginator_model_view_middle_page() {
480        let mut p = Paginator::new().total_pages(5);
481        p.set_page(2);
482        assert_eq!(p.view(), "3/5");
483    }
484
485    #[test]
486    fn test_paginator_model_view_last_page() {
487        let mut p = Paginator::new().total_pages(5);
488        p.set_page(4);
489        assert_eq!(p.view(), "5/5");
490    }
491
492    #[test]
493    fn test_paginator_model_view_single_page() {
494        let p = Paginator::new().total_pages(1);
495        assert_eq!(p.view(), "1/1");
496    }
497
498    #[test]
499    fn test_paginator_model_view_dots_first_page() {
500        let p = Paginator::new().display_type(Type::Dots).total_pages(3);
501        assert_eq!(p.view(), "•○○");
502    }
503
504    #[test]
505    fn test_paginator_model_view_dots_middle_page() {
506        let mut p = Paginator::new().display_type(Type::Dots).total_pages(3);
507        p.set_page(1);
508        assert_eq!(p.view(), "○•○");
509    }
510
511    #[test]
512    fn test_paginator_model_view_dots_last_page() {
513        let mut p = Paginator::new().display_type(Type::Dots).total_pages(3);
514        p.set_page(2);
515        assert_eq!(p.view(), "○○•");
516    }
517}