1#![forbid(unsafe_code)]
2
3use 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#[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 #[must_use]
61 pub fn current_page(mut self, current_page: u64) -> Self {
62 self.current_page = current_page;
63 self
64 }
65
66 #[must_use]
68 pub fn total_pages(mut self, total_pages: u64) -> Self {
69 self.total_pages = total_pages;
70 self
71 }
72
73 #[must_use]
75 pub fn mode(mut self, mode: PaginatorMode) -> Self {
76 self.mode = mode;
77 self
78 }
79
80 #[must_use]
82 pub fn style(mut self, style: Style) -> Self {
83 self.style = style;
84 self
85 }
86
87 #[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 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 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 }
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}