#![forbid(unsafe_code)]
use crate::{Widget, clear_text_row, draw_text_span};
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::display_width;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaginatorMode {
Page,
Compact,
Dots,
}
#[derive(Debug, Clone)]
pub struct Paginator<'a> {
current_page: u64,
total_pages: u64,
mode: PaginatorMode,
style: Style,
active_symbol: &'a str,
inactive_symbol: &'a str,
}
impl<'a> Default for Paginator<'a> {
fn default() -> Self {
Self {
current_page: 0,
total_pages: 0,
mode: PaginatorMode::Compact,
style: Style::default(),
active_symbol: "*",
inactive_symbol: ".",
}
}
}
impl<'a> Paginator<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn with_pages(current_page: u64, total_pages: u64) -> Self {
Self::default()
.current_page(current_page)
.total_pages(total_pages)
}
#[must_use]
pub fn current_page(mut self, current_page: u64) -> Self {
self.current_page = current_page;
self
}
#[must_use]
pub fn total_pages(mut self, total_pages: u64) -> Self {
self.total_pages = total_pages;
self
}
#[must_use]
pub fn mode(mut self, mode: PaginatorMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn dots_symbols(mut self, active: &'a str, inactive: &'a str) -> Self {
self.active_symbol = active;
self.inactive_symbol = inactive;
self
}
fn normalized_pages(&self) -> (u64, u64) {
let total = self.total_pages;
if total == 0 {
return (0, 0);
}
let current = self.current_page.clamp(1, total);
(current, total)
}
fn format_compact(&self) -> String {
let (current, total) = self.normalized_pages();
format!("{current}/{total}")
}
fn format_page(&self) -> String {
let (current, total) = self.normalized_pages();
format!("Page {current}/{total}")
}
fn format_dots(&self, max_width: usize) -> Option<String> {
let (current, total) = self.normalized_pages();
if total == 0 || max_width == 0 {
return None;
}
let active_width = display_width(self.active_symbol);
let inactive_width = display_width(self.inactive_symbol);
let symbol_width = active_width.max(inactive_width);
if symbol_width == 0 {
return None;
}
let max_dots = max_width / symbol_width;
if max_dots == 0 {
return None;
}
let total_usize = total as usize;
if total_usize > max_dots {
return None;
}
let mut out = String::new();
for idx in 1..=total_usize {
if idx as u64 == current {
out.push_str(self.active_symbol);
} else {
out.push_str(self.inactive_symbol);
}
}
if display_width(out.as_str()) > max_width {
return None;
}
Some(out)
}
fn format_for_width(&self, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
match self.mode {
PaginatorMode::Page => self.format_page(),
PaginatorMode::Compact => self.format_compact(),
PaginatorMode::Dots => self
.format_dots(max_width)
.unwrap_or_else(|| self.format_compact()),
}
}
}
impl Widget for Paginator<'_> {
fn render(&self, area: Rect, frame: &mut Frame) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"widget_render",
widget = "Paginator",
x = area.x,
y = area.y,
w = area.width,
h = area.height
)
.entered();
if area.is_empty() || area.height == 0 {
return;
}
let deg = frame.buffer.degradation;
let style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
clear_text_row(frame, area, style);
let text = self.format_for_width(area.width as usize);
if text.is_empty() {
return;
}
draw_text_span(frame, area.x, area.y, &text, style, area.right());
}
fn is_essential(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::budget::DegradationLevel;
use ftui_render::cell::Cell;
use ftui_render::grapheme_pool::GraphemePool;
#[test]
fn compact_zero_total() {
let pager = Paginator::new().mode(PaginatorMode::Compact);
assert_eq!(pager.format_for_width(10), "0/0");
}
#[test]
fn page_clamps_current() {
let pager = Paginator::with_pages(10, 3).mode(PaginatorMode::Page);
assert_eq!(pager.format_for_width(20), "Page 3/3");
}
#[test]
fn compact_clamps_zero_current() {
let pager = Paginator::with_pages(0, 5).mode(PaginatorMode::Compact);
assert_eq!(pager.format_for_width(10), "1/5");
}
#[test]
fn dots_basic() {
let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
assert_eq!(pager.format_for_width(10), "..*..");
}
#[test]
fn dots_fallbacks_when_too_narrow() {
let pager = Paginator::with_pages(5, 10).mode(PaginatorMode::Dots);
assert_eq!(pager.format_for_width(5), "5/10");
}
#[test]
fn compact_one_page() {
let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Compact);
assert_eq!(pager.format_for_width(10), "1/1");
}
#[test]
fn page_one_page() {
let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Page);
assert_eq!(pager.format_for_width(20), "Page 1/1");
}
#[test]
fn dots_one_page() {
let pager = Paginator::with_pages(1, 1).mode(PaginatorMode::Dots);
assert_eq!(pager.format_for_width(10), "*");
}
#[test]
fn compact_large_counts() {
let pager = Paginator::with_pages(999, 1000).mode(PaginatorMode::Compact);
assert_eq!(pager.format_for_width(20), "999/1000");
}
#[test]
fn page_large_counts() {
let pager = Paginator::with_pages(42, 9999).mode(PaginatorMode::Page);
assert_eq!(pager.format_for_width(30), "Page 42/9999");
}
#[test]
fn zero_width_returns_empty() {
let pager = Paginator::with_pages(1, 5).mode(PaginatorMode::Compact);
assert_eq!(pager.format_for_width(0), "");
}
#[test]
fn dots_zero_total() {
let pager = Paginator::new().mode(PaginatorMode::Dots);
assert_eq!(pager.format_for_width(10), "0/0");
}
#[test]
fn page_zero_total() {
let pager = Paginator::new().mode(PaginatorMode::Page);
assert_eq!(pager.format_for_width(20), "Page 0/0");
}
#[test]
fn dots_first_page() {
let pager = Paginator::with_pages(1, 5).mode(PaginatorMode::Dots);
assert_eq!(pager.format_for_width(10), "*....");
}
#[test]
fn dots_last_page() {
let pager = Paginator::with_pages(5, 5).mode(PaginatorMode::Dots);
assert_eq!(pager.format_for_width(10), "....*");
}
#[test]
fn dots_custom_symbols() {
let pager = Paginator::with_pages(2, 4)
.mode(PaginatorMode::Dots)
.dots_symbols("●", "○");
assert_eq!(pager.format_for_width(20), "○●○○");
}
#[test]
fn builder_chain() {
let pager = Paginator::new()
.current_page(3)
.total_pages(7)
.mode(PaginatorMode::Compact)
.style(Style::default());
assert_eq!(pager.format_for_width(10), "3/7");
}
#[test]
fn normalized_pages_clamps_high() {
let pager = Paginator::with_pages(100, 5);
let (cur, total) = pager.normalized_pages();
assert_eq!(cur, 5);
assert_eq!(total, 5);
}
#[test]
fn normalized_pages_clamps_zero() {
let pager = Paginator::with_pages(0, 5);
let (cur, total) = pager.normalized_pages();
assert_eq!(cur, 1);
assert_eq!(total, 5);
}
#[test]
fn normalized_pages_zero_total() {
let pager = Paginator::new();
let (cur, total) = pager.normalized_pages();
assert_eq!(cur, 0);
assert_eq!(total, 0);
}
#[test]
fn render_on_empty_area() {
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 10, &mut pool);
let pager = Paginator::with_pages(1, 5);
pager.render(area, &mut frame);
}
#[test]
fn render_compact() {
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Compact);
pager.render(area, &mut frame);
let mut text = String::new();
for x in 0..10u16 {
if let Some(cell) = frame.buffer.get(x, 0)
&& let Some(ch) = cell.content.as_char()
{
text.push(ch);
}
}
assert!(text.starts_with("2/5"), "got: {text}");
}
#[test]
fn is_essential() {
let pager = Paginator::new();
assert!(pager.is_essential());
}
#[test]
fn default_mode_is_compact() {
let pager = Paginator::new();
assert_eq!(pager.mode, PaginatorMode::Compact);
}
#[test]
fn with_pages_constructor() {
let pager = Paginator::with_pages(3, 10);
assert_eq!(pager.current_page, 3);
assert_eq!(pager.total_pages, 10);
}
#[test]
fn render_page_mode() {
let area = Rect::new(0, 0, 15, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(15, 1, &mut pool);
let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Page);
pager.render(area, &mut frame);
let mut text = String::new();
for x in 0..15u16 {
if let Some(cell) = frame.buffer.get(x, 0)
&& let Some(ch) = cell.content.as_char()
{
text.push(ch);
}
}
assert!(text.starts_with("Page 2/5"), "got: {text}");
}
#[test]
fn render_dots_mode() {
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
pager.render(area, &mut frame);
let mut text = String::new();
for x in 0..10u16 {
if let Some(cell) = frame.buffer.get(x, 0)
&& let Some(ch) = cell.content.as_char()
{
text.push(ch);
}
}
assert!(text.starts_with("..*.."), "got: {text}");
}
#[test]
fn render_clears_stale_suffix_cells() {
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
frame.buffer.set_fast(5, 0, Cell::from_char('X'));
let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Compact);
pager.render(area, &mut frame);
assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some(' '));
}
#[test]
fn dots_middle_page() {
let pager = Paginator::with_pages(3, 5).mode(PaginatorMode::Dots);
assert_eq!(pager.format_for_width(10), "..*..");
}
#[test]
fn dots_symbols_default_star_and_dot() {
let pager = Paginator::new();
assert_eq!(pager.active_symbol, "*");
assert_eq!(pager.inactive_symbol, ".");
}
#[test]
fn skeleton_renders_paginator_as_essential_text() {
let area = Rect::new(0, 0, 12, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(12, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::Skeleton;
let pager = Paginator::with_pages(2, 5).mode(PaginatorMode::Compact);
pager.render(area, &mut frame);
let mut text = String::new();
for x in 0..12u16 {
if let Some(cell) = frame.buffer.get(x, 0)
&& let Some(ch) = cell.content.as_char()
{
text.push(ch);
}
}
assert!(text.starts_with("2/5"), "got: {text}");
}
#[test]
fn skeleton_shorter_paginator_clears_stale_suffix() {
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let long = Paginator::with_pages(3, 9).mode(PaginatorMode::Page);
let short = Paginator::new().mode(PaginatorMode::Compact);
long.render(area, &mut frame);
frame.buffer.degradation = DegradationLevel::Skeleton;
short.render(area, &mut frame);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('/'));
assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('0'));
assert_eq!(frame.buffer.get(3, 0).unwrap().content.as_char(), Some(' '));
assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some(' '));
}
}