1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PaginatorMode {
14 Page,
16 Compact,
18 Dots,
20}
21
22#[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 pub fn new() -> Self {
49 Self::default()
50 }
51
52 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 pub fn current_page(mut self, current_page: u64) -> Self {
61 self.current_page = current_page;
62 self
63 }
64
65 pub fn total_pages(mut self, total_pages: u64) -> Self {
67 self.total_pages = total_pages;
68 self
69 }
70
71 pub fn mode(mut self, mode: PaginatorMode) -> Self {
73 self.mode = mode;
74 self
75 }
76
77 pub fn style(mut self, style: Style) -> Self {
79 self.style = style;
80 self
81 }
82
83 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 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 }
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}