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 #[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 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 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 }
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}