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    pub fn current_page(mut self, current_page: u64) -> Self {
61        self.current_page = current_page;
62        self
63    }
64
65    /// Set the total pages.
66    pub fn total_pages(mut self, total_pages: u64) -> Self {
67        self.total_pages = total_pages;
68        self
69    }
70
71    /// Set the display mode.
72    pub fn mode(mut self, mode: PaginatorMode) -> Self {
73        self.mode = mode;
74        self
75    }
76
77    /// Set the overall style for the paginator text.
78    pub fn style(mut self, style: Style) -> Self {
79        self.style = style;
80        self
81    }
82
83    /// Set the symbols used for dot mode.
84    pub fn dots_symbols(mut self, active: &'a str, inactive: &'a str) -> Self {
85        self.active_symbol = active;
86        self.inactive_symbol = inactive;
87        self
88    }
89
90    fn normalized_pages(&self) -> (u64, u64) {
91        let total = self.total_pages;
92        if total == 0 {
93            return (0, 0);
94        }
95        let current = self.current_page.clamp(1, total);
96        (current, total)
97    }
98
99    fn format_compact(&self) -> String {
100        let (current, total) = self.normalized_pages();
101        format!("{current}/{total}")
102    }
103
104    fn format_page(&self) -> String {
105        let (current, total) = self.normalized_pages();
106        format!("Page {current}/{total}")
107    }
108
109    fn format_dots(&self, max_width: usize) -> Option<String> {
110        let (current, total) = self.normalized_pages();
111        if total == 0 || max_width == 0 {
112            return None;
113        }
114
115        let active_width = display_width(self.active_symbol);
116        let inactive_width = display_width(self.inactive_symbol);
117        let symbol_width = active_width.max(inactive_width);
118        if symbol_width == 0 {
119            return None;
120        }
121
122        let max_dots = max_width / symbol_width;
123        if max_dots == 0 {
124            return None;
125        }
126
127        let total_usize = total as usize;
128        if total_usize > max_dots {
129            return None;
130        }
131
132        let mut out = String::new();
133        for idx in 1..=total_usize {
134            if idx as u64 == current {
135                out.push_str(self.active_symbol);
136            } else {
137                out.push_str(self.inactive_symbol);
138            }
139        }
140
141        if display_width(out.as_str()) > max_width {
142            return None;
143        }
144        Some(out)
145    }
146
147    fn format_for_width(&self, max_width: usize) -> String {
148        if max_width == 0 {
149            return String::new();
150        }
151
152        match self.mode {
153            PaginatorMode::Page => self.format_page(),
154            PaginatorMode::Compact => self.format_compact(),
155            PaginatorMode::Dots => self
156                .format_dots(max_width)
157                .unwrap_or_else(|| self.format_compact()),
158        }
159    }
160}
161
162impl Widget for Paginator<'_> {
163    fn render(&self, area: Rect, frame: &mut Frame) {
164        #[cfg(feature = "tracing")]
165        let _span = tracing::debug_span!(
166            "widget_render",
167            widget = "Paginator",
168            x = area.x,
169            y = area.y,
170            w = area.width,
171            h = area.height
172        )
173        .entered();
174
175        if area.is_empty() || area.height == 0 {
176            return;
177        }
178
179        let deg = frame.buffer.degradation;
180        if !deg.render_content() {
181            return;
182        }
183
184        let style = if deg.apply_styling() {
185            self.style
186        } else {
187            Style::default()
188        };
189
190        let text = self.format_for_width(area.width as usize);
191        if text.is_empty() {
192            return;
193        }
194
195        draw_text_span(frame, area.x, area.y, &text, style, area.right());
196    }
197
198    fn is_essential(&self) -> bool {
199        true
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use ftui_render::grapheme_pool::GraphemePool;
207
208    #[test]
209    fn compact_zero_total() {
210        let pager = Paginator::new().mode(PaginatorMode::Compact);
211        assert_eq!(pager.format_for_width(10), "0/0");
212    }
213
214    #[test]
215    fn page_clamps_current() {
216        let pager = Paginator::with_pages(10, 3).mode(PaginatorMode::Page);
217        assert_eq!(pager.format_for_width(20), "Page 3/3");
218    }
219
220    #[test]
221    fn compact_clamps_zero_current() {
222        let pager = Paginator::with_pages(0, 5).mode(PaginatorMode::Compact);
223        assert_eq!(pager.format_for_width(10), "1/5");
224    }
225
226    #[test]
227    fn dots_basic() {
228        let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
229        assert_eq!(pager.format_for_width(10), "..*..");
230    }
231
232    #[test]
233    fn dots_fallbacks_when_too_narrow() {
234        let pager = Paginator::with_pages(5, 10).mode(PaginatorMode::Dots);
235        assert_eq!(pager.format_for_width(5), "5/10");
236    }
237
238    #[test]
239    fn compact_one_page() {
240        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Compact);
241        assert_eq!(pager.format_for_width(10), "1/1");
242    }
243
244    #[test]
245    fn page_one_page() {
246        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Page);
247        assert_eq!(pager.format_for_width(20), "Page 1/1");
248    }
249
250    #[test]
251    fn dots_one_page() {
252        let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Dots);
253        assert_eq!(pager.format_for_width(10), "*");
254    }
255
256    #[test]
257    fn compact_large_counts() {
258        let pager = Paginator::with_pages(999, 1000).mode(PaginatorMode::Compact);
259        assert_eq!(pager.format_for_width(20), "999/1000");
260    }
261
262    #[test]
263    fn page_large_counts() {
264        let pager = Paginator::with_pages(42, 9999).mode(PaginatorMode::Page);
265        assert_eq!(pager.format_for_width(30), "Page 42/9999");
266    }
267
268    #[test]
269    fn zero_width_returns_empty() {
270        let pager = Paginator::with_pages(1, 5).mode(PaginatorMode::Compact);
271        assert_eq!(pager.format_for_width(0), "");
272    }
273
274    #[test]
275    fn dots_zero_total() {
276        let pager = Paginator::new().mode(PaginatorMode::Dots);
277        // Falls back to compact: "0/0"
278        assert_eq!(pager.format_for_width(10), "0/0");
279    }
280
281    #[test]
282    fn page_zero_total() {
283        let pager = Paginator::new().mode(PaginatorMode::Page);
284        assert_eq!(pager.format_for_width(20), "Page 0/0");
285    }
286
287    #[test]
288    fn dots_first_page() {
289        let pager = Paginator::with_pages(1, 5).mode(PaginatorMode::Dots);
290        assert_eq!(pager.format_for_width(10), "*....");
291    }
292
293    #[test]
294    fn dots_last_page() {
295        let pager = Paginator::with_pages(5, 5).mode(PaginatorMode::Dots);
296        assert_eq!(pager.format_for_width(10), "....*");
297    }
298
299    #[test]
300    fn dots_custom_symbols() {
301        let pager = Paginator::with_pages(2, 4)
302            .mode(PaginatorMode::Dots)
303            .dots_symbols("●", "○");
304        assert_eq!(pager.format_for_width(20), "○●○○");
305    }
306
307    #[test]
308    fn builder_chain() {
309        let pager = Paginator::new()
310            .current_page(3)
311            .total_pages(7)
312            .mode(PaginatorMode::Compact)
313            .style(Style::default());
314        assert_eq!(pager.format_for_width(10), "3/7");
315    }
316
317    #[test]
318    fn normalized_pages_clamps_high() {
319        let pager = Paginator::with_pages(100, 5);
320        let (cur, total) = pager.normalized_pages();
321        assert_eq!(cur, 5);
322        assert_eq!(total, 5);
323    }
324
325    #[test]
326    fn normalized_pages_clamps_zero() {
327        let pager = Paginator::with_pages(0, 5);
328        let (cur, total) = pager.normalized_pages();
329        assert_eq!(cur, 1);
330        assert_eq!(total, 5);
331    }
332
333    #[test]
334    fn normalized_pages_zero_total() {
335        let pager = Paginator::new();
336        let (cur, total) = pager.normalized_pages();
337        assert_eq!(cur, 0);
338        assert_eq!(total, 0);
339    }
340
341    #[test]
342    fn render_on_empty_area() {
343        let area = Rect::new(0, 0, 0, 0);
344        let mut pool = GraphemePool::new();
345        let mut frame = Frame::new(10, 10, &mut pool);
346        let pager = Paginator::with_pages(1, 5);
347        pager.render(area, &mut frame);
348        // No panic, nothing drawn
349    }
350
351    #[test]
352    fn render_compact() {
353        let area = Rect::new(0, 0, 10, 1);
354        let mut pool = GraphemePool::new();
355        let mut frame = Frame::new(10, 1, &mut pool);
356        let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Compact);
357        pager.render(area, &mut frame);
358        let mut text = String::new();
359        for x in 0..10u16 {
360            if let Some(cell) = frame.buffer.get(x, 0)
361                && let Some(ch) = cell.content.as_char()
362            {
363                text.push(ch);
364            }
365        }
366        assert!(text.starts_with("2/5"), "got: {text}");
367    }
368
369    #[test]
370    fn is_essential() {
371        let pager = Paginator::new();
372        assert!(pager.is_essential());
373    }
374
375    #[test]
376    fn default_mode_is_compact() {
377        let pager = Paginator::new();
378        assert_eq!(pager.mode, PaginatorMode::Compact);
379    }
380
381    #[test]
382    fn with_pages_constructor() {
383        let pager = Paginator::with_pages(3, 10);
384        assert_eq!(pager.current_page, 3);
385        assert_eq!(pager.total_pages, 10);
386    }
387}