Skip to main content

ftui_widgets/
paginator.rs

1#![forbid(unsafe_code)]
2
3//! Paginator widget.
4
5use crate::{Widget, 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        if !deg.render_content() {
186            return;
187        }
188
189        let style = if deg.apply_styling() {
190            self.style
191        } else {
192            Style::default()
193        };
194
195        let text = self.format_for_width(area.width as usize);
196        if text.is_empty() {
197            return;
198        }
199
200        draw_text_span(frame, area.x, area.y, &text, style, area.right());
201    }
202
203    fn is_essential(&self) -> bool {
204        true
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use ftui_render::grapheme_pool::GraphemePool;
212
213    #[test]
214    fn compact_zero_total() {
215        let pager = Paginator::new().mode(PaginatorMode::Compact);
216        assert_eq!(pager.format_for_width(10), "0/0");
217    }
218
219    #[test]
220    fn page_clamps_current() {
221        let pager = Paginator::with_pages(10, 3).mode(PaginatorMode::Page);
222        assert_eq!(pager.format_for_width(20), "Page 3/3");
223    }
224
225    #[test]
226    fn compact_clamps_zero_current() {
227        let pager = Paginator::with_pages(0, 5).mode(PaginatorMode::Compact);
228        assert_eq!(pager.format_for_width(10), "1/5");
229    }
230
231    #[test]
232    fn dots_basic() {
233        let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
234        assert_eq!(pager.format_for_width(10), "..*..");
235    }
236
237    #[test]
238    fn dots_fallbacks_when_too_narrow() {
239        let pager = Paginator::with_pages(5, 10).mode(PaginatorMode::Dots);
240        assert_eq!(pager.format_for_width(5), "5/10");
241    }
242
243    #[test]
244    fn compact_one_page() {
245        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Compact);
246        assert_eq!(pager.format_for_width(10), "1/1");
247    }
248
249    #[test]
250    fn page_one_page() {
251        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Page);
252        assert_eq!(pager.format_for_width(20), "Page 1/1");
253    }
254
255    #[test]
256    fn dots_one_page() {
257        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Dots);
258        assert_eq!(pager.format_for_width(10), "*");
259    }
260
261    #[test]
262    fn compact_large_counts() {
263        let pager = Paginator::with_pages(999, 1000).mode(PaginatorMode::Compact);
264        assert_eq!(pager.format_for_width(20), "999/1000");
265    }
266
267    #[test]
268    fn page_large_counts() {
269        let pager = Paginator::with_pages(42, 9999).mode(PaginatorMode::Page);
270        assert_eq!(pager.format_for_width(30), "Page 42/9999");
271    }
272
273    #[test]
274    fn zero_width_returns_empty() {
275        let pager = Paginator::with_pages(1, 5).mode(PaginatorMode::Compact);
276        assert_eq!(pager.format_for_width(0), "");
277    }
278
279    #[test]
280    fn dots_zero_total() {
281        let pager = Paginator::new().mode(PaginatorMode::Dots);
282        // Falls back to compact: "0/0"
283        assert_eq!(pager.format_for_width(10), "0/0");
284    }
285
286    #[test]
287    fn page_zero_total() {
288        let pager = Paginator::new().mode(PaginatorMode::Page);
289        assert_eq!(pager.format_for_width(20), "Page 0/0");
290    }
291
292    #[test]
293    fn dots_first_page() {
294        let pager = Paginator::with_pages(1, 5).mode(PaginatorMode::Dots);
295        assert_eq!(pager.format_for_width(10), "*....");
296    }
297
298    #[test]
299    fn dots_last_page() {
300        let pager = Paginator::with_pages(5, 5).mode(PaginatorMode::Dots);
301        assert_eq!(pager.format_for_width(10), "....*");
302    }
303
304    #[test]
305    fn dots_custom_symbols() {
306        let pager = Paginator::with_pages(2, 4)
307            .mode(PaginatorMode::Dots)
308            .dots_symbols("●", "○");
309        assert_eq!(pager.format_for_width(20), "○●○○");
310    }
311
312    #[test]
313    fn builder_chain() {
314        let pager = Paginator::new()
315            .current_page(3)
316            .total_pages(7)
317            .mode(PaginatorMode::Compact)
318            .style(Style::default());
319        assert_eq!(pager.format_for_width(10), "3/7");
320    }
321
322    #[test]
323    fn normalized_pages_clamps_high() {
324        let pager = Paginator::with_pages(100, 5);
325        let (cur, total) = pager.normalized_pages();
326        assert_eq!(cur, 5);
327        assert_eq!(total, 5);
328    }
329
330    #[test]
331    fn normalized_pages_clamps_zero() {
332        let pager = Paginator::with_pages(0, 5);
333        let (cur, total) = pager.normalized_pages();
334        assert_eq!(cur, 1);
335        assert_eq!(total, 5);
336    }
337
338    #[test]
339    fn normalized_pages_zero_total() {
340        let pager = Paginator::new();
341        let (cur, total) = pager.normalized_pages();
342        assert_eq!(cur, 0);
343        assert_eq!(total, 0);
344    }
345
346    #[test]
347    fn render_on_empty_area() {
348        let area = Rect::new(0, 0, 0, 0);
349        let mut pool = GraphemePool::new();
350        let mut frame = Frame::new(10, 10, &mut pool);
351        let pager = Paginator::with_pages(1, 5);
352        pager.render(area, &mut frame);
353        // No panic, nothing drawn
354    }
355
356    #[test]
357    fn render_compact() {
358        let area = Rect::new(0, 0, 10, 1);
359        let mut pool = GraphemePool::new();
360        let mut frame = Frame::new(10, 1, &mut pool);
361        let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Compact);
362        pager.render(area, &mut frame);
363        let mut text = String::new();
364        for x in 0..10u16 {
365            if let Some(cell) = frame.buffer.get(x, 0)
366                && let Some(ch) = cell.content.as_char()
367            {
368                text.push(ch);
369            }
370        }
371        assert!(text.starts_with("2/5"), "got: {text}");
372    }
373
374    #[test]
375    fn is_essential() {
376        let pager = Paginator::new();
377        assert!(pager.is_essential());
378    }
379
380    #[test]
381    fn default_mode_is_compact() {
382        let pager = Paginator::new();
383        assert_eq!(pager.mode, PaginatorMode::Compact);
384    }
385
386    #[test]
387    fn with_pages_constructor() {
388        let pager = Paginator::with_pages(3, 10);
389        assert_eq!(pager.current_page, 3);
390        assert_eq!(pager.total_pages, 10);
391    }
392
393    #[test]
394    fn render_page_mode() {
395        let area = Rect::new(0, 0, 15, 1);
396        let mut pool = GraphemePool::new();
397        let mut frame = Frame::new(15, 1, &mut pool);
398        let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Page);
399        pager.render(area, &mut frame);
400        let mut text = String::new();
401        for x in 0..15u16 {
402            if let Some(cell) = frame.buffer.get(x, 0)
403                && let Some(ch) = cell.content.as_char()
404            {
405                text.push(ch);
406            }
407        }
408        assert!(text.starts_with("Page 2/5"), "got: {text}");
409    }
410
411    #[test]
412    fn render_dots_mode() {
413        let area = Rect::new(0, 0, 10, 1);
414        let mut pool = GraphemePool::new();
415        let mut frame = Frame::new(10, 1, &mut pool);
416        let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
417        pager.render(area, &mut frame);
418        let mut text = String::new();
419        for x in 0..10u16 {
420            if let Some(cell) = frame.buffer.get(x, 0)
421                && let Some(ch) = cell.content.as_char()
422            {
423                text.push(ch);
424            }
425        }
426        assert_eq!(text, "..*..", "got: {text}");
427    }
428
429    #[test]
430    fn dots_middle_page() {
431        let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
432        assert_eq!(pager.format_for_width(10), "..*..");
433    }
434
435    #[test]
436    fn dots_symbols_default_star_and_dot() {
437        let pager = Paginator::new();
438        assert_eq!(pager.active_symbol, "*");
439        assert_eq!(pager.inactive_symbol, ".");
440    }
441}