Skip to main content

ftui_widgets/
paginator.rs

1#![forbid(unsafe_code)]
2
3//! Paginator widget.
4
5use crate::{Widget, clear_text_row, draw_text_span};
6use ftui_core::geometry::Rect;
7use ftui_render::frame::Frame;
8use ftui_style::Style;
9use ftui_text::display_width;
10
11/// Display mode for the paginator.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PaginatorMode {
14    /// Render as "Page X/Y".
15    Page,
16    /// Render as "X/Y".
17    Compact,
18    /// Render as dot indicators (e.g. "**.*").
19    Dots,
20}
21
22/// A simple paginator widget for page indicators.
23#[derive(Debug, Clone)]
24pub struct Paginator<'a> {
25    current_page: u64,
26    total_pages: u64,
27    mode: PaginatorMode,
28    style: Style,
29    active_symbol: &'a str,
30    inactive_symbol: &'a str,
31}
32
33impl<'a> Default for Paginator<'a> {
34    fn default() -> Self {
35        Self {
36            current_page: 0,
37            total_pages: 0,
38            mode: PaginatorMode::Compact,
39            style: Style::default(),
40            active_symbol: "*",
41            inactive_symbol: ".",
42        }
43    }
44}
45
46impl<'a> Paginator<'a> {
47    /// Create a new paginator with default settings.
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Create a paginator with the provided page counts.
53    pub fn with_pages(current_page: u64, total_pages: u64) -> Self {
54        Self::default()
55            .current_page(current_page)
56            .total_pages(total_pages)
57    }
58
59    /// Set the current page (1-based).
60    #[must_use]
61    pub fn current_page(mut self, current_page: u64) -> Self {
62        self.current_page = current_page;
63        self
64    }
65
66    /// Set the total pages.
67    #[must_use]
68    pub fn total_pages(mut self, total_pages: u64) -> Self {
69        self.total_pages = total_pages;
70        self
71    }
72
73    /// Set the display mode.
74    #[must_use]
75    pub fn mode(mut self, mode: PaginatorMode) -> Self {
76        self.mode = mode;
77        self
78    }
79
80    /// Set the overall style for the paginator text.
81    #[must_use]
82    pub fn style(mut self, style: Style) -> Self {
83        self.style = style;
84        self
85    }
86
87    /// Set the symbols used for dot mode.
88    #[must_use]
89    pub fn dots_symbols(mut self, active: &'a str, inactive: &'a str) -> Self {
90        self.active_symbol = active;
91        self.inactive_symbol = inactive;
92        self
93    }
94
95    fn normalized_pages(&self) -> (u64, u64) {
96        let total = self.total_pages;
97        if total == 0 {
98            return (0, 0);
99        }
100        let current = self.current_page.clamp(1, total);
101        (current, total)
102    }
103
104    fn format_compact(&self) -> String {
105        let (current, total) = self.normalized_pages();
106        format!("{current}/{total}")
107    }
108
109    fn format_page(&self) -> String {
110        let (current, total) = self.normalized_pages();
111        format!("Page {current}/{total}")
112    }
113
114    fn format_dots(&self, max_width: usize) -> Option<String> {
115        let (current, total) = self.normalized_pages();
116        if total == 0 || max_width == 0 {
117            return None;
118        }
119
120        let active_width = display_width(self.active_symbol);
121        let inactive_width = display_width(self.inactive_symbol);
122        let symbol_width = active_width.max(inactive_width);
123        if symbol_width == 0 {
124            return None;
125        }
126
127        let max_dots = max_width / symbol_width;
128        if max_dots == 0 {
129            return None;
130        }
131
132        let total_usize = total as usize;
133        if total_usize > max_dots {
134            return None;
135        }
136
137        let mut out = String::new();
138        for idx in 1..=total_usize {
139            if idx as u64 == current {
140                out.push_str(self.active_symbol);
141            } else {
142                out.push_str(self.inactive_symbol);
143            }
144        }
145
146        if display_width(out.as_str()) > max_width {
147            return None;
148        }
149        Some(out)
150    }
151
152    fn format_for_width(&self, max_width: usize) -> String {
153        if max_width == 0 {
154            return String::new();
155        }
156
157        match self.mode {
158            PaginatorMode::Page => self.format_page(),
159            PaginatorMode::Compact => self.format_compact(),
160            PaginatorMode::Dots => self
161                .format_dots(max_width)
162                .unwrap_or_else(|| self.format_compact()),
163        }
164    }
165}
166
167impl Widget for Paginator<'_> {
168    fn render(&self, area: Rect, frame: &mut Frame) {
169        #[cfg(feature = "tracing")]
170        let _span = tracing::debug_span!(
171            "widget_render",
172            widget = "Paginator",
173            x = area.x,
174            y = area.y,
175            w = area.width,
176            h = area.height
177        )
178        .entered();
179
180        if area.is_empty() || area.height == 0 {
181            return;
182        }
183
184        let deg = frame.buffer.degradation;
185        // Paginator state is essential navigation context, so it still renders
186        // plain text in Skeleton mode instead of disappearing entirely.
187
188        let style = if deg.apply_styling() {
189            self.style
190        } else {
191            Style::default()
192        };
193
194        clear_text_row(frame, area, style);
195
196        let text = self.format_for_width(area.width as usize);
197        if text.is_empty() {
198            return;
199        }
200
201        draw_text_span(frame, area.x, area.y, &text, style, area.right());
202    }
203
204    fn is_essential(&self) -> bool {
205        true
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use ftui_render::budget::DegradationLevel;
213    use ftui_render::cell::Cell;
214    use ftui_render::grapheme_pool::GraphemePool;
215
216    #[test]
217    fn compact_zero_total() {
218        let pager = Paginator::new().mode(PaginatorMode::Compact);
219        assert_eq!(pager.format_for_width(10), "0/0");
220    }
221
222    #[test]
223    fn page_clamps_current() {
224        let pager = Paginator::with_pages(10, 3).mode(PaginatorMode::Page);
225        assert_eq!(pager.format_for_width(20), "Page 3/3");
226    }
227
228    #[test]
229    fn compact_clamps_zero_current() {
230        let pager = Paginator::with_pages(0, 5).mode(PaginatorMode::Compact);
231        assert_eq!(pager.format_for_width(10), "1/5");
232    }
233
234    #[test]
235    fn dots_basic() {
236        let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
237        assert_eq!(pager.format_for_width(10), "..*..");
238    }
239
240    #[test]
241    fn dots_fallbacks_when_too_narrow() {
242        let pager = Paginator::with_pages(5, 10).mode(PaginatorMode::Dots);
243        assert_eq!(pager.format_for_width(5), "5/10");
244    }
245
246    #[test]
247    fn compact_one_page() {
248        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Compact);
249        assert_eq!(pager.format_for_width(10), "1/1");
250    }
251
252    #[test]
253    fn page_one_page() {
254        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Page);
255        assert_eq!(pager.format_for_width(20), "Page 1/1");
256    }
257
258    #[test]
259    fn dots_one_page() {
260        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Dots);
261        assert_eq!(pager.format_for_width(10), "*");
262    }
263
264    #[test]
265    fn compact_large_counts() {
266        let pager = Paginator::with_pages(999, 1000).mode(PaginatorMode::Compact);
267        assert_eq!(pager.format_for_width(20), "999/1000");
268    }
269
270    #[test]
271    fn page_large_counts() {
272        let pager = Paginator::with_pages(42, 9999).mode(PaginatorMode::Page);
273        assert_eq!(pager.format_for_width(30), "Page 42/9999");
274    }
275
276    #[test]
277    fn zero_width_returns_empty() {
278        let pager = Paginator::with_pages(1, 5).mode(PaginatorMode::Compact);
279        assert_eq!(pager.format_for_width(0), "");
280    }
281
282    #[test]
283    fn dots_zero_total() {
284        let pager = Paginator::new().mode(PaginatorMode::Dots);
285        // Falls back to compact: "0/0"
286        assert_eq!(pager.format_for_width(10), "0/0");
287    }
288
289    #[test]
290    fn page_zero_total() {
291        let pager = Paginator::new().mode(PaginatorMode::Page);
292        assert_eq!(pager.format_for_width(20), "Page 0/0");
293    }
294
295    #[test]
296    fn dots_first_page() {
297        let pager = Paginator::with_pages(1, 5).mode(PaginatorMode::Dots);
298        assert_eq!(pager.format_for_width(10), "*....");
299    }
300
301    #[test]
302    fn dots_last_page() {
303        let pager = Paginator::with_pages(5, 5).mode(PaginatorMode::Dots);
304        assert_eq!(pager.format_for_width(10), "....*");
305    }
306
307    #[test]
308    fn dots_custom_symbols() {
309        let pager = Paginator::with_pages(2, 4)
310            .mode(PaginatorMode::Dots)
311            .dots_symbols("●", "○");
312        assert_eq!(pager.format_for_width(20), "○●○○");
313    }
314
315    #[test]
316    fn builder_chain() {
317        let pager = Paginator::new()
318            .current_page(3)
319            .total_pages(7)
320            .mode(PaginatorMode::Compact)
321            .style(Style::default());
322        assert_eq!(pager.format_for_width(10), "3/7");
323    }
324
325    #[test]
326    fn normalized_pages_clamps_high() {
327        let pager = Paginator::with_pages(100, 5);
328        let (cur, total) = pager.normalized_pages();
329        assert_eq!(cur, 5);
330        assert_eq!(total, 5);
331    }
332
333    #[test]
334    fn normalized_pages_clamps_zero() {
335        let pager = Paginator::with_pages(0, 5);
336        let (cur, total) = pager.normalized_pages();
337        assert_eq!(cur, 1);
338        assert_eq!(total, 5);
339    }
340
341    #[test]
342    fn normalized_pages_zero_total() {
343        let pager = Paginator::new();
344        let (cur, total) = pager.normalized_pages();
345        assert_eq!(cur, 0);
346        assert_eq!(total, 0);
347    }
348
349    #[test]
350    fn render_on_empty_area() {
351        let area = Rect::new(0, 0, 0, 0);
352        let mut pool = GraphemePool::new();
353        let mut frame = Frame::new(10, 10, &mut pool);
354        let pager = Paginator::with_pages(1, 5);
355        pager.render(area, &mut frame);
356        // No panic, nothing drawn
357    }
358
359    #[test]
360    fn render_compact() {
361        let area = Rect::new(0, 0, 10, 1);
362        let mut pool = GraphemePool::new();
363        let mut frame = Frame::new(10, 1, &mut pool);
364        let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Compact);
365        pager.render(area, &mut frame);
366        let mut text = String::new();
367        for x in 0..10u16 {
368            if let Some(cell) = frame.buffer.get(x, 0)
369                && let Some(ch) = cell.content.as_char()
370            {
371                text.push(ch);
372            }
373        }
374        assert!(text.starts_with("2/5"), "got: {text}");
375    }
376
377    #[test]
378    fn is_essential() {
379        let pager = Paginator::new();
380        assert!(pager.is_essential());
381    }
382
383    #[test]
384    fn default_mode_is_compact() {
385        let pager = Paginator::new();
386        assert_eq!(pager.mode, PaginatorMode::Compact);
387    }
388
389    #[test]
390    fn with_pages_constructor() {
391        let pager = Paginator::with_pages(3, 10);
392        assert_eq!(pager.current_page, 3);
393        assert_eq!(pager.total_pages, 10);
394    }
395
396    #[test]
397    fn render_page_mode() {
398        let area = Rect::new(0, 0, 15, 1);
399        let mut pool = GraphemePool::new();
400        let mut frame = Frame::new(15, 1, &mut pool);
401        let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Page);
402        pager.render(area, &mut frame);
403        let mut text = String::new();
404        for x in 0..15u16 {
405            if let Some(cell) = frame.buffer.get(x, 0)
406                && let Some(ch) = cell.content.as_char()
407            {
408                text.push(ch);
409            }
410        }
411        assert!(text.starts_with("Page 2/5"), "got: {text}");
412    }
413
414    #[test]
415    fn render_dots_mode() {
416        let area = Rect::new(0, 0, 10, 1);
417        let mut pool = GraphemePool::new();
418        let mut frame = Frame::new(10, 1, &mut pool);
419        let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
420        pager.render(area, &mut frame);
421        let mut text = String::new();
422        for x in 0..10u16 {
423            if let Some(cell) = frame.buffer.get(x, 0)
424                && let Some(ch) = cell.content.as_char()
425            {
426                text.push(ch);
427            }
428        }
429        assert!(text.starts_with("..*.."), "got: {text}");
430    }
431
432    #[test]
433    fn render_clears_stale_suffix_cells() {
434        let area = Rect::new(0, 0, 10, 1);
435        let mut pool = GraphemePool::new();
436        let mut frame = Frame::new(10, 1, &mut pool);
437        frame.buffer.set_fast(5, 0, Cell::from_char('X'));
438        let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Compact);
439
440        pager.render(area, &mut frame);
441
442        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some(' '));
443    }
444
445    #[test]
446    fn dots_middle_page() {
447        let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
448        assert_eq!(pager.format_for_width(10), "..*..");
449    }
450
451    #[test]
452    fn dots_symbols_default_star_and_dot() {
453        let pager = Paginator::new();
454        assert_eq!(pager.active_symbol, "*");
455        assert_eq!(pager.inactive_symbol, ".");
456    }
457
458    #[test]
459    fn skeleton_renders_paginator_as_essential_text() {
460        let area = Rect::new(0, 0, 12, 1);
461        let mut pool = GraphemePool::new();
462        let mut frame = Frame::new(12, 1, &mut pool);
463        frame.buffer.degradation = DegradationLevel::Skeleton;
464        let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Compact);
465
466        pager.render(area, &mut frame);
467
468        let mut text = String::new();
469        for x in 0..12u16 {
470            if let Some(cell) = frame.buffer.get(x, 0)
471                && let Some(ch) = cell.content.as_char()
472            {
473                text.push(ch);
474            }
475        }
476        assert!(text.starts_with("2/5"), "got: {text}");
477    }
478
479    #[test]
480    fn skeleton_shorter_paginator_clears_stale_suffix() {
481        let area = Rect::new(0, 0, 10, 1);
482        let mut pool = GraphemePool::new();
483        let mut frame = Frame::new(10, 1, &mut pool);
484        let long = Paginator::with_pages(3, 9).mode(PaginatorMode::Page);
485        let short = Paginator::new().mode(PaginatorMode::Compact);
486
487        long.render(area, &mut frame);
488        frame.buffer.degradation = DegradationLevel::Skeleton;
489        short.render(area, &mut frame);
490
491        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
492        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('/'));
493        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('0'));
494        assert_eq!(frame.buffer.get(3, 0).unwrap().content.as_char(), Some(' '));
495        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some(' '));
496    }
497}